Very very basic account creation (but not doing anythin with it yet) using Vault
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
[submodule "gm-java"]
|
||||
path = gm-java
|
||||
url = ssh://gitea@git.qpq.swiss:21203/QPQ-AG/gm-java.git
|
||||
Generated
-3
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
GajuMobile
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-23
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="main">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="unitTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="androidTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-13
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-18
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PlanningModeManager">
|
||||
<option name="approvalStates">
|
||||
<map>
|
||||
<entry key="530b5232-7563-454f-bdcb-0099e4b00732" value="false" />
|
||||
<entry key="a82de12b-d0aa-44d8-b9e8-e753cde67ff4" value="false" />
|
||||
<entry key="b3cb996b-7f1f-4810-999d-5be96c526f74" value="false" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Binary file not shown.
@@ -30,12 +30,18 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDir("../gm-java/src/main/java")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -4,39 +4,41 @@ import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import swiss.qpq.gajumobile.data.AppStateManager
|
||||
import swiss.qpq.gajumobile.data.WalletRepository
|
||||
import swiss.qpq.gajumobile.data.models.Account
|
||||
import swiss.qpq.gajumobile.data.models.Wallet
|
||||
import swiss.qpq.gajumobile.security.AppLifecycleObserver
|
||||
import swiss.qpq.gajumobile.security.BiometricHelper
|
||||
import swiss.qpq.gajumobile.security.SecurityManager
|
||||
import swiss.qpq.gajumobile.ui.screens.CreateAccountMnemonicScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.DashboardScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.DeleteConfirmationScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.ExplorerScreen
|
||||
import swiss.qpq.gajumobile.security.KeyManager
|
||||
import swiss.qpq.gajumobile.security.AccountAirlock
|
||||
import swiss.qpq.gajumobile.ui.screens.LockScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.ManageAccountScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.ManageOverviewScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.ManageWalletScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.MnemonicGridScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.MnemonicVerifyScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.QRReceiveScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.QRScannerScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.RecoverMnemonicScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.SendFormScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.SendReviewScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.CreateWalletScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.SetupChoiceScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.SetupSuccessScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.TransactionSuccessScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.MnemonicRecoveryScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.AccountNamingScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.DashboardScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.QRScannerScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.QRReceiveScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.SettingsScreen
|
||||
import swiss.qpq.gajumobile.ui.screens.ManageOverviewScreen
|
||||
import swiss.qpq.gajumobile.ui.theme.MyApplicationTheme
|
||||
import swiss.qpq.gajumaru.core.encoding.ApiEncoder
|
||||
import android.widget.Toast
|
||||
import java.util.UUID
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -45,36 +47,37 @@ class MainActivity : FragmentActivity() {
|
||||
enableEdgeToEdge()
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
|
||||
// Prevent screenshots and obscure recents preview
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
|
||||
// Initialize App Lifecycle Security Observer
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver(this))
|
||||
|
||||
setContent {
|
||||
val context = LocalContext.current
|
||||
val isLocked by SecurityManager.isLocked.collectAsState()
|
||||
var lockErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val walletRepo = remember { WalletRepository(context) }
|
||||
val appStateManager = remember { AppStateManager(context) }
|
||||
|
||||
MyApplicationTheme {
|
||||
if (isLocked) {
|
||||
LockScreen(
|
||||
errorMessage = lockErrorMessage,
|
||||
onUnlockRequest = {
|
||||
BiometricHelper.showBiometricPrompt(
|
||||
activity = this,
|
||||
onSuccess = {
|
||||
android.util.Log.d("GajuSecurity", "Unlock success")
|
||||
lockErrorMessage = null
|
||||
SecurityManager.unlock()
|
||||
},
|
||||
) { err ->
|
||||
android.util.Log.e("GajuSecurity", "Unlock error: $err")
|
||||
lockErrorMessage = err
|
||||
}
|
||||
) {
|
||||
BiometricHelper.showBiometricPrompt(
|
||||
activity = this,
|
||||
onSuccess = {
|
||||
lockErrorMessage = null
|
||||
SecurityManager.unlock()
|
||||
},
|
||||
) { err ->
|
||||
lockErrorMessage = err
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
GajuRouter()
|
||||
val routerError: (String) -> Unit = { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
GajuRouter(walletRepo, appStateManager, routerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,11 +85,10 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
|
||||
enum class Screen {
|
||||
CREATE_WALLET,
|
||||
SETUP_CHOICE,
|
||||
CREATE_MNEMONIC,
|
||||
MNEMONIC_GRID,
|
||||
MNEMONIC_VERIFY,
|
||||
RECOVER_MNEMONIC,
|
||||
MNEMONIC_RECOVERY,
|
||||
ACCOUNT_NAMING,
|
||||
SETUP_SUCCESS,
|
||||
DASHBOARD,
|
||||
QR_SCANNER,
|
||||
@@ -103,52 +105,108 @@ enum class Screen {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GajuRouter() {
|
||||
var currentScreen by rememberSaveable { mutableStateOf(value = Screen.SETUP_CHOICE) }
|
||||
var setupRecovered by rememberSaveable { mutableStateOf(value = false) }
|
||||
var selectedWallet by rememberSaveable { mutableStateOf(value = "Primary") }
|
||||
var selectedAccount by rememberSaveable { mutableStateOf(value = "Daily") }
|
||||
var deleteType by rememberSaveable { mutableStateOf(value = "WALLET") }
|
||||
var deleteName by rememberSaveable { mutableStateOf(value = "") }
|
||||
fun GajuRouter(
|
||||
walletRepo: WalletRepository,
|
||||
appStateManager: AppStateManager,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Determine start screen
|
||||
val initialScreen = remember {
|
||||
if (walletRepo.listWallets().isEmpty()) {
|
||||
Screen.CREATE_WALLET
|
||||
} else {
|
||||
Screen.DASHBOARD
|
||||
}
|
||||
}
|
||||
|
||||
var currentScreen by rememberSaveable { mutableStateOf(initialScreen) }
|
||||
|
||||
var pendingWalletName by rememberSaveable { mutableStateOf("") }
|
||||
var pendingAccount by remember { mutableStateOf<Account?>(null) }
|
||||
var setupRecovered by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
when (currentScreen) {
|
||||
Screen.CREATE_WALLET -> {
|
||||
CreateWalletScreen { name ->
|
||||
pendingWalletName = name
|
||||
currentScreen = Screen.SETUP_CHOICE
|
||||
}
|
||||
}
|
||||
Screen.SETUP_CHOICE -> {
|
||||
SetupChoiceScreen(
|
||||
onCreate = {
|
||||
setupRecovered = false
|
||||
currentScreen = Screen.CREATE_MNEMONIC
|
||||
android.util.Log.d("GajuRouter", "CREATE ACCOUNT clicked")
|
||||
try {
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
android.util.Log.d("GajuRouter", "Master key retrieved")
|
||||
val phrase = AccountAirlock.generateMnemonic()
|
||||
android.util.Log.d("GajuRouter", "Mnemonic generated")
|
||||
val account = AccountAirlock.createAccount("Account 1", phrase, masterKey)
|
||||
android.util.Log.d("GajuRouter", "Account created: ${account.id}")
|
||||
AccountAirlock.wipePhrase(phrase)
|
||||
|
||||
pendingAccount = account
|
||||
setupRecovered = false
|
||||
currentScreen = Screen.ACCOUNT_NAMING
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("GajuRouter", "Failed to generate account", e)
|
||||
onError("Failed to generate account: ${e.message}")
|
||||
}
|
||||
},
|
||||
) {
|
||||
setupRecovered = true
|
||||
currentScreen = Screen.RECOVER_MNEMONIC
|
||||
}
|
||||
}
|
||||
Screen.CREATE_MNEMONIC -> {
|
||||
CreateAccountMnemonicScreen(
|
||||
onNext = { currentScreen = Screen.MNEMONIC_GRID },
|
||||
) { currentScreen = Screen.SETUP_CHOICE }
|
||||
}
|
||||
Screen.MNEMONIC_GRID -> {
|
||||
MnemonicGridScreen(
|
||||
onNext = { currentScreen = Screen.MNEMONIC_VERIFY },
|
||||
) { currentScreen = Screen.CREATE_MNEMONIC }
|
||||
}
|
||||
Screen.MNEMONIC_VERIFY -> {
|
||||
MnemonicVerifyScreen(
|
||||
onFinish = { currentScreen = Screen.SETUP_SUCCESS },
|
||||
) { currentScreen = Screen.MNEMONIC_GRID }
|
||||
}
|
||||
Screen.RECOVER_MNEMONIC -> {
|
||||
RecoverMnemonicScreen(
|
||||
onRecover = { currentScreen = Screen.SETUP_SUCCESS },
|
||||
) { currentScreen = Screen.SETUP_CHOICE }
|
||||
}
|
||||
Screen.SETUP_SUCCESS -> {
|
||||
SetupSuccessScreen(
|
||||
isRecovered = setupRecovered,
|
||||
onProceed = { currentScreen = Screen.DASHBOARD },
|
||||
onRecover = {
|
||||
setupRecovered = true
|
||||
currentScreen = Screen.MNEMONIC_RECOVERY
|
||||
}
|
||||
)
|
||||
}
|
||||
Screen.MNEMONIC_RECOVERY -> {
|
||||
MnemonicRecoveryScreen(
|
||||
onMnemonicConfirmed = { phrase ->
|
||||
android.util.Log.d("GajuRouter", "RECOVER ACCOUNT clicked")
|
||||
try {
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
android.util.Log.d("GajuRouter", "Master key retrieved")
|
||||
val account = AccountAirlock.createAccount("Account 1", phrase, masterKey)
|
||||
android.util.Log.d("GajuRouter", "Account recovered: ${account.id}")
|
||||
AccountAirlock.wipePhrase(phrase)
|
||||
|
||||
pendingAccount = account
|
||||
currentScreen = Screen.ACCOUNT_NAMING
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("GajuRouter", "Failed to recover account", e)
|
||||
onError("Failed to recover account: ${e.message}")
|
||||
}
|
||||
},
|
||||
onBack = { currentScreen = Screen.SETUP_CHOICE }
|
||||
)
|
||||
}
|
||||
Screen.ACCOUNT_NAMING -> {
|
||||
val account = pendingAccount
|
||||
if (account == null) {
|
||||
android.util.Log.e("GajuRouter", "pendingAccount is NULL in ACCOUNT_NAMING")
|
||||
return
|
||||
}
|
||||
val akId = ApiEncoder.encode(ApiEncoder.Type.ACCOUNT_PUBKEY, account.publicKey)
|
||||
android.util.Log.d("GajuRouter", "Showing ACCOUNT_NAMING for $akId")
|
||||
|
||||
AccountNamingScreen(publicKey = akId) { name ->
|
||||
val finalAccount = account.copy(label = name)
|
||||
val wallet = Wallet(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = pendingWalletName.ifBlank { "My Wallet" },
|
||||
accounts = listOf(finalAccount)
|
||||
)
|
||||
walletRepo.saveWallet(wallet)
|
||||
|
||||
if (!setupRecovered) {
|
||||
appStateManager.markUnverified(finalAccount.id)
|
||||
}
|
||||
|
||||
currentScreen = Screen.DASHBOARD
|
||||
}
|
||||
}
|
||||
Screen.DASHBOARD -> {
|
||||
DashboardScreen(
|
||||
onSend = { currentScreen = Screen.QR_SCANNER },
|
||||
@@ -163,106 +221,14 @@ fun GajuRouter() {
|
||||
},
|
||||
)
|
||||
}
|
||||
Screen.QR_SCANNER -> {
|
||||
QRScannerScreen(
|
||||
onGenerate = { currentScreen = Screen.QR_RECEIVE },
|
||||
onUpload = { /* TODO */ },
|
||||
) { currentScreen = Screen.DASHBOARD }
|
||||
}
|
||||
Screen.QR_RECEIVE -> {
|
||||
QRReceiveScreen(
|
||||
onDone = { currentScreen = Screen.DASHBOARD },
|
||||
) { currentScreen = Screen.DASHBOARD }
|
||||
}
|
||||
Screen.SEND_FORM -> {
|
||||
SendFormScreen(
|
||||
onReview = { currentScreen = Screen.SEND_REVIEW },
|
||||
) { currentScreen = Screen.DASHBOARD }
|
||||
}
|
||||
Screen.SEND_REVIEW -> {
|
||||
SendReviewScreen(
|
||||
onSend = { currentScreen = Screen.SEND_SUCCESS },
|
||||
) { currentScreen = Screen.SEND_FORM }
|
||||
}
|
||||
Screen.SEND_SUCCESS -> {
|
||||
TransactionSuccessScreen { currentScreen = Screen.DASHBOARD }
|
||||
}
|
||||
Screen.SETTINGS -> {
|
||||
SettingsScreen(
|
||||
onNavigate = { dest ->
|
||||
currentScreen = when (dest) {
|
||||
"dashboard" -> Screen.DASHBOARD
|
||||
"manage" -> Screen.MANAGE_OVERVIEW
|
||||
"transactions" -> Screen.EXPLORER
|
||||
else -> Screen.SETTINGS
|
||||
}
|
||||
},
|
||||
onBack = { currentScreen = Screen.DASHBOARD },
|
||||
)
|
||||
}
|
||||
Screen.MANAGE_OVERVIEW -> {
|
||||
ManageOverviewScreen(
|
||||
onWalletDetail = {
|
||||
selectedWallet = it
|
||||
currentScreen = Screen.MANAGE_WALLET
|
||||
},
|
||||
onAccountDetail = {
|
||||
selectedAccount = it
|
||||
currentScreen = Screen.MANAGE_ACCOUNT
|
||||
},
|
||||
onNavigate = { dest ->
|
||||
currentScreen = when (dest) {
|
||||
"dashboard" -> Screen.DASHBOARD
|
||||
"settings" -> Screen.SETTINGS
|
||||
"transactions" -> Screen.EXPLORER
|
||||
else -> Screen.MANAGE_OVERVIEW
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Screen.MANAGE_WALLET -> {
|
||||
ManageWalletScreen(
|
||||
walletName = selectedWallet,
|
||||
onDelete = {
|
||||
deleteType = "WALLET"
|
||||
deleteName = selectedWallet
|
||||
currentScreen = Screen.DELETE_CONFIRM
|
||||
},
|
||||
onBack = { currentScreen = Screen.MANAGE_OVERVIEW },
|
||||
)
|
||||
}
|
||||
Screen.MANAGE_ACCOUNT -> {
|
||||
ManageAccountScreen(
|
||||
accountName = selectedAccount,
|
||||
walletName = selectedWallet,
|
||||
onDelete = {
|
||||
deleteType = "ACCOUNT"
|
||||
deleteName = selectedAccount
|
||||
currentScreen = Screen.DELETE_CONFIRM
|
||||
},
|
||||
onBack = { currentScreen = Screen.MANAGE_OVERVIEW },
|
||||
)
|
||||
}
|
||||
Screen.DELETE_CONFIRM -> {
|
||||
DeleteConfirmationScreen(
|
||||
type = deleteType,
|
||||
name = deleteName,
|
||||
onConfirm = { currentScreen = Screen.MANAGE_OVERVIEW },
|
||||
onCancel = { currentScreen = Screen.MANAGE_OVERVIEW },
|
||||
)
|
||||
}
|
||||
Screen.EXPLORER -> {
|
||||
ExplorerScreen(
|
||||
onNavigate = { dest ->
|
||||
currentScreen = when (dest) {
|
||||
"dashboard" -> Screen.DASHBOARD
|
||||
"settings" -> Screen.SETTINGS
|
||||
"manage" -> Screen.MANAGE_OVERVIEW
|
||||
else -> Screen.EXPLORER
|
||||
}
|
||||
},
|
||||
onBack = { currentScreen = Screen.DASHBOARD },
|
||||
)
|
||||
// Fallback mapping for existing screens
|
||||
Screen.QR_SCANNER -> QRScannerScreen(onGenerate = { currentScreen = Screen.QR_RECEIVE }, onUpload = {}) { currentScreen = Screen.DASHBOARD }
|
||||
Screen.QR_RECEIVE -> QRReceiveScreen(onDone = { currentScreen = Screen.DASHBOARD }) { currentScreen = Screen.DASHBOARD }
|
||||
Screen.SETTINGS -> SettingsScreen(onNavigate = { currentScreen = Screen.DASHBOARD }, onBack = { currentScreen = Screen.DASHBOARD })
|
||||
Screen.MANAGE_OVERVIEW -> ManageOverviewScreen(onWalletDetail = {}, onAccountDetail = {}, onNavigate = { currentScreen = Screen.DASHBOARD })
|
||||
|
||||
else -> {
|
||||
Text("Screen $currentScreen not yet fully wired")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package swiss.qpq.gajumobile.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class AppState(
|
||||
val unverifiedAccountIds: Set<String> = emptySet(),
|
||||
val lastPesteredAt: Long = 0L
|
||||
)
|
||||
|
||||
class AppStateManager(context: Context) {
|
||||
private val json = Json { prettyPrint = true }
|
||||
private val stateFile = File(context.filesDir, "app_state.json")
|
||||
|
||||
fun loadState(): AppState {
|
||||
return if (stateFile.exists()) {
|
||||
try {
|
||||
json.decodeFromString(stateFile.readText())
|
||||
} catch (_: Exception) {
|
||||
AppState()
|
||||
}
|
||||
} else {
|
||||
AppState()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveState(state: AppState) {
|
||||
stateFile.writeText(json.encodeToString(state))
|
||||
}
|
||||
|
||||
fun markUnverified(accountId: String) {
|
||||
val current = loadState()
|
||||
saveState(current.copy(unverifiedAccountIds = current.unverifiedAccountIds + accountId))
|
||||
}
|
||||
|
||||
fun clearVerified(accountId: String) {
|
||||
val current = loadState()
|
||||
saveState(current.copy(unverifiedAccountIds = current.unverifiedAccountIds - accountId))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package swiss.qpq.gajumobile.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import swiss.qpq.gajumobile.data.models.Account
|
||||
import swiss.qpq.gajumobile.data.models.Wallet
|
||||
import swiss.qpq.gajumobile.security.AccountAirlock
|
||||
import swiss.qpq.gajumobile.security.EncryptionService
|
||||
import swiss.qpq.gajumobile.security.KeyManager
|
||||
import java.io.File
|
||||
|
||||
class WalletRepository(private val context: Context) {
|
||||
@@ -13,7 +15,7 @@ class WalletRepository(private val context: Context) {
|
||||
|
||||
fun saveWallet(wallet: Wallet) {
|
||||
val walletJson = json.encodeToString(wallet)
|
||||
val envelope = EncryptionService.encrypt(context, walletJson)
|
||||
val envelope = EncryptionService.encrypt(context, walletJson.toByteArray())
|
||||
val envelopeJson = json.encodeToString(envelope)
|
||||
|
||||
val file = getWalletFile(wallet.id)
|
||||
@@ -27,14 +29,27 @@ class WalletRepository(private val context: Context) {
|
||||
return try {
|
||||
val envelopeJson = file.readText()
|
||||
val envelope = json.decodeFromString<swiss.qpq.gajumobile.security.EncryptedEnvelope>(envelopeJson)
|
||||
val walletJson = EncryptionService.decrypt(context, envelope)
|
||||
json.decodeFromString<Wallet>(walletJson)
|
||||
val walletBytes = EncryptionService.decrypt(context, envelope)
|
||||
json.decodeFromString<Wallet>(String(walletBytes))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("WalletRepository", "Failed to load wallet", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun signMessage(account: Account, message: ByteArray): ByteArray {
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
return AccountAirlock.sign(account.privateKeyEnvelope, masterKey, message)
|
||||
}
|
||||
|
||||
fun listWallets(): List<String> {
|
||||
val dir = File(context.filesDir, "wallets")
|
||||
if (!dir.exists()) return emptyList()
|
||||
return dir.listFiles { _, name -> name.startsWith("wallet_") && name.endsWith(".gaju") }
|
||||
?.map { it.name.removePrefix("wallet_").removeSuffix(".gaju") }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
private fun getWalletFile(walletId: String): File {
|
||||
val dir = File(context.filesDir, "wallets")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
package swiss.qpq.gajumobile.data.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import swiss.qpq.gajumobile.security.EncryptedEnvelope
|
||||
|
||||
@Serializable
|
||||
data class Account(
|
||||
val id: String, // Unique identifier (e.g., UUID or derived from pubkey)
|
||||
val label: String,
|
||||
val publicKey: String, // Base64 encoded public key
|
||||
val encryptedPrivateKey: String, // Base64 encoded private key (encrypted by DEK)
|
||||
val publicKey: ByteArray,
|
||||
val privateKeyEnvelope: EncryptedEnvelope,
|
||||
val cachedBalance: CachedBalance? = null
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Account
|
||||
|
||||
if (id != other.id) return false
|
||||
if (label != other.label) return false
|
||||
if (!publicKey.contentEquals(other.publicKey)) return false
|
||||
if (privateKeyEnvelope != other.privateKeyEnvelope) return false
|
||||
if (cachedBalance != other.cachedBalance) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + label.hashCode()
|
||||
result = 31 * result + publicKey.contentHashCode()
|
||||
result = 31 * result + privateKeyEnvelope.hashCode()
|
||||
result = 31 * result + (cachedBalance?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CachedBalance(
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
|
||||
* Project: Gajumaru Mobile Wallet
|
||||
*/
|
||||
|
||||
package swiss.qpq.gajumobile.security;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import swiss.qpq.gajumaru.core.crypto.Ed25519;
|
||||
import swiss.qpq.gajumaru.core.crypto.Vault;
|
||||
import swiss.qpq.gajumaru.core.encoding.Mnemonic;
|
||||
import swiss.qpq.gajumaru.core.tools.CryptoUtils;
|
||||
import swiss.qpq.gajumobile.data.models.Account;
|
||||
|
||||
/**
|
||||
* AccountAirlock provides a secure Java-based bridge for identity operations.
|
||||
* It ensures that sensitive material only exists in local stack-allocated
|
||||
* buffers and is wiped immediately after use.
|
||||
*/
|
||||
public final class AccountAirlock {
|
||||
|
||||
private AccountAirlock() {}
|
||||
|
||||
/**
|
||||
* Returns the 4096-word Gajumaru dictionary.
|
||||
*/
|
||||
public static String[] getWordList() {
|
||||
return Mnemonic.WORDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new random mnemonic phrase.
|
||||
*
|
||||
* @return The 23-word mnemonic phrase as a byte array of UTF-8 words.
|
||||
*/
|
||||
public static byte[][] generateMnemonic() {
|
||||
byte[] entropy = new byte[32];
|
||||
new SecureRandom().nextBytes(entropy);
|
||||
try {
|
||||
return Mnemonic.encode(entropy);
|
||||
} finally {
|
||||
CryptoUtils.wipe(entropy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Account object from a mnemonic phrase.
|
||||
*
|
||||
* @param label The user-defined label for the account.
|
||||
* @param phrase The mnemonic phrase as a list of UTF-8 word byte arrays.
|
||||
* @param masterKey The hardware-backed Master Key handle.
|
||||
* @return The new Account object.
|
||||
* @throws Exception if creation or encryption fails.
|
||||
*/
|
||||
public static Account createAccount(
|
||||
String label,
|
||||
byte[][] phrase,
|
||||
SecretKey masterKey
|
||||
) throws Exception {
|
||||
|
||||
byte[] seed = null;
|
||||
byte[] dekBytes = null;
|
||||
|
||||
try {
|
||||
// 1. Decode mnemonic to seed
|
||||
seed = Mnemonic.decode(phrase);
|
||||
|
||||
// 2. Generate Public Key
|
||||
byte[] publicKey = Ed25519.publicKey(seed);
|
||||
|
||||
// 3. Generate random Data Encryption Key (DEK)
|
||||
SecureRandom random = new SecureRandom();
|
||||
dekBytes = new byte[32];
|
||||
random.nextBytes(dekBytes);
|
||||
SecretKey dek = new SecretKeySpec(dekBytes, "AES");
|
||||
|
||||
// 4. Encrypt Private Key (seed) with DEK
|
||||
// We use the provider-generated IV here for maximum hardware compatibility
|
||||
Vault.Ciphertext dataCiphertext = Vault.encrypt(dek, seed);
|
||||
|
||||
// 5. Encrypt DEK with Master Key
|
||||
// Android Keystore REQUIRED to generate the IV when randomizedEncryption is true
|
||||
Vault.Ciphertext dekCiphertext = Vault.encrypt(masterKey, dekBytes);
|
||||
|
||||
EncryptedEnvelope envelope = new EncryptedEnvelope(
|
||||
dataCiphertext.getData(),
|
||||
dekCiphertext.getData(),
|
||||
dataCiphertext.getIv(),
|
||||
dekCiphertext.getIv()
|
||||
);
|
||||
|
||||
String id = UUID.randomUUID().toString();
|
||||
return new Account(id, label, publicKey, envelope, null);
|
||||
|
||||
} finally {
|
||||
// 6. Memory Hygiene: Wipe sensitive data from the heap
|
||||
if (seed != null) {
|
||||
CryptoUtils.wipe(seed);
|
||||
}
|
||||
if (dekBytes != null) {
|
||||
CryptoUtils.wipe(dekBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to convert a UI String into the internal airlock byte[][] format.
|
||||
*/
|
||||
public static byte[][] stringToPhrase(String phrase) {
|
||||
String[] words = phrase.trim().split("\\s+");
|
||||
byte[][] result = new byte[words.length][];
|
||||
for (int i = 0; i < words.length; i++) {
|
||||
result[i] = words[i].getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to wipe a phrase returned by the airlock.
|
||||
*/
|
||||
public static void wipePhrase(byte[][] phrase) {
|
||||
if (phrase != null) {
|
||||
for (byte[] word : phrase) {
|
||||
CryptoUtils.wipe(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an account's private key and signs a message.
|
||||
*
|
||||
* @param envelope The encrypted private key envelope.
|
||||
* @param masterKey The hardware-backed Master Key handle from Android Keystore.
|
||||
* @param message The message to sign.
|
||||
* @return The Ed25519 signature.
|
||||
* @throws Exception if decryption or signing fails.
|
||||
*/
|
||||
public static byte[] sign(
|
||||
EncryptedEnvelope envelope,
|
||||
SecretKey masterKey,
|
||||
byte[] message
|
||||
) throws Exception {
|
||||
|
||||
byte[] plaintextDek = null;
|
||||
byte[] plaintextPrivateKey = null;
|
||||
|
||||
try {
|
||||
// 1. Decrypt the Data Encryption Key (DEK) using the Master Key
|
||||
plaintextDek = Vault.decrypt(
|
||||
masterKey,
|
||||
envelope.getDekIv(),
|
||||
envelope.getEncryptedDek()
|
||||
);
|
||||
SecretKey dek = new SecretKeySpec(plaintextDek, "AES");
|
||||
|
||||
// 2. Decrypt the Private Key using the DEK
|
||||
plaintextPrivateKey = Vault.decrypt(
|
||||
dek,
|
||||
envelope.getDataIv(),
|
||||
envelope.getEncryptedData()
|
||||
);
|
||||
|
||||
// 3. Perform Ed25519 signature
|
||||
// The Ed25519 implementation in gm-java uses internal scratch pads
|
||||
// that are also wiped upon completion.
|
||||
return Ed25519.sign(plaintextPrivateKey, message);
|
||||
|
||||
} finally {
|
||||
// 4. Memory Hygiene: Wipe sensitive data from the heap
|
||||
if (plaintextDek != null) {
|
||||
CryptoUtils.wipe(plaintextDek);
|
||||
}
|
||||
if (plaintextPrivateKey != null) {
|
||||
CryptoUtils.wipe(plaintextPrivateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package swiss.qpq.gajumobile.security
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -14,18 +13,38 @@ import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@Serializable
|
||||
data class EncryptedEnvelope(
|
||||
val encryptedData: String, // Base64
|
||||
val encryptedDek: String, // Base64
|
||||
val dataIv: String, // Base64
|
||||
val dekIv: String // Base64
|
||||
)
|
||||
val encryptedData: ByteArray,
|
||||
val encryptedDek: ByteArray,
|
||||
val dataIv: ByteArray,
|
||||
val dekIv: ByteArray,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as EncryptedEnvelope
|
||||
|
||||
if (!encryptedData.contentEquals(other.encryptedData)) return false
|
||||
if (!encryptedDek.contentEquals(other.encryptedDek)) return false
|
||||
if (!dataIv.contentEquals(other.dataIv)) return false
|
||||
return dekIv.contentEquals(other.dekIv)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = encryptedData.contentHashCode()
|
||||
result = (31 * result) + encryptedDek.contentHashCode()
|
||||
result = (31 * result) + dataIv.contentHashCode()
|
||||
result = (31 * result) + dekIv.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
object EncryptionService {
|
||||
private const val AES_GCM = "AES/GCM/NoPadding"
|
||||
private const val GCM_TAG_LENGTH = 128
|
||||
private const val IV_LENGTH = 12
|
||||
|
||||
fun encrypt(context: Context, data: String): EncryptedEnvelope {
|
||||
fun encrypt(context: Context, data: ByteArray): EncryptedEnvelope {
|
||||
// 1. Generate a random Data Encryption Key (DEK) in software
|
||||
val dek = generateDek()
|
||||
|
||||
@@ -33,7 +52,7 @@ object EncryptionService {
|
||||
val dataCipher = Cipher.getInstance(AES_GCM)
|
||||
val dataIv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) }
|
||||
dataCipher.init(Cipher.ENCRYPT_MODE, dek, GCMParameterSpec(GCM_TAG_LENGTH, dataIv))
|
||||
val encryptedData = dataCipher.doFinal(data.toByteArray())
|
||||
val encryptedData = dataCipher.doFinal(data)
|
||||
|
||||
// 3. Encrypt the DEK with the Master Key (Hardware-backed)
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
@@ -44,29 +63,25 @@ object EncryptionService {
|
||||
val dekIv = dekCipher.iv
|
||||
|
||||
return EncryptedEnvelope(
|
||||
encryptedData = Base64.encodeToString(encryptedData, Base64.NO_WRAP),
|
||||
encryptedDek = Base64.encodeToString(encryptedDek, Base64.NO_WRAP),
|
||||
dataIv = Base64.encodeToString(dataIv, Base64.NO_WRAP),
|
||||
dekIv = Base64.encodeToString(dekIv, Base64.NO_WRAP)
|
||||
encryptedData = encryptedData,
|
||||
encryptedDek = encryptedDek,
|
||||
dataIv = dataIv,
|
||||
dekIv = dekIv
|
||||
)
|
||||
}
|
||||
|
||||
fun decrypt(context: Context, envelope: EncryptedEnvelope): String {
|
||||
fun decrypt(context: Context, envelope: EncryptedEnvelope): ByteArray {
|
||||
// 1. Decrypt the DEK using the Master Key
|
||||
val masterKey = KeyManager.getMasterKey(context)
|
||||
val dekCipher = Cipher.getInstance(AES_GCM)
|
||||
val dekIv = Base64.decode(envelope.dekIv, Base64.NO_WRAP)
|
||||
dekCipher.init(Cipher.DECRYPT_MODE, masterKey, GCMParameterSpec(GCM_TAG_LENGTH, dekIv))
|
||||
val decryptedDekBytes = dekCipher.doFinal(Base64.decode(envelope.encryptedDek, Base64.NO_WRAP))
|
||||
dekCipher.init(Cipher.DECRYPT_MODE, masterKey, GCMParameterSpec(GCM_TAG_LENGTH, envelope.dekIv))
|
||||
val decryptedDekBytes = dekCipher.doFinal(envelope.encryptedDek)
|
||||
val dek = SecretKeySpec(decryptedDekBytes, "AES")
|
||||
|
||||
// 2. Decrypt the data using the DEK
|
||||
val dataCipher = Cipher.getInstance(AES_GCM)
|
||||
val dataIv = Base64.decode(envelope.dataIv, Base64.NO_WRAP)
|
||||
dataCipher.init(Cipher.DECRYPT_MODE, dek, GCMParameterSpec(GCM_TAG_LENGTH, dataIv))
|
||||
val decryptedData = dataCipher.doFinal(Base64.decode(envelope.encryptedData, Base64.NO_WRAP))
|
||||
|
||||
return String(decryptedData)
|
||||
dataCipher.init(Cipher.DECRYPT_MODE, dek, GCMParameterSpec(GCM_TAG_LENGTH, envelope.dataIv))
|
||||
return dataCipher.doFinal(envelope.encryptedData)
|
||||
}
|
||||
|
||||
private fun generateDek(): SecretKey {
|
||||
|
||||
@@ -16,6 +16,10 @@ object KeyManager {
|
||||
fun getMasterKey(context: Context): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
|
||||
if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
|
||||
keyStore.deleteEntry(MASTER_KEY_ALIAS)
|
||||
}
|
||||
|
||||
return if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
|
||||
keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey
|
||||
} else {
|
||||
@@ -23,6 +27,17 @@ object KeyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcefully deletes the master key.
|
||||
* Use this if you hit "Caller-provided IV" errors during development.
|
||||
*/
|
||||
fun resetMasterKey() {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
|
||||
keyStore.deleteEntry(MASTER_KEY_ALIAS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateMasterKey(context: Context): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
@@ -36,6 +51,7 @@ object KeyManager {
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.setRandomizedEncryptionRequired(true) // Stronger: hardware module generates high-entropy IV
|
||||
|
||||
// Target StrongBox if available (Android 9+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
|
||||
@@ -51,11 +51,13 @@ fun GajuButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
@@ -349,8 +351,8 @@ fun SuccessLayout(
|
||||
GajuButton(
|
||||
text = proceedText,
|
||||
onClick = onProceed,
|
||||
containerColor = if (checked) Color.White else Color.LightGray,
|
||||
contentColor = Color.Black,
|
||||
containerColor = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (checked) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,8 +114,10 @@ fun SettingsScreen(
|
||||
checked = biometricsEnabled,
|
||||
onCheckedChange = { biometricsEnabled = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -190,7 +192,7 @@ fun ManageOverviewScreen(
|
||||
FloatingActionButton(
|
||||
onClick = { /* TODO */ },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = Color.White,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(painterResource(R.drawable.ic_favorite), contentDescription = "Add Wallet")
|
||||
@@ -210,7 +212,7 @@ private fun WalletCard(
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onWalletClick() },
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -219,8 +221,8 @@ private fun WalletCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(32.dp))
|
||||
Text(text = name, color = Color.Black, fontWeight = FontWeight.Bold)
|
||||
Icon(painterResource(R.drawable.settings), null, tint = Color.Black, modifier = Modifier.size(24.dp))
|
||||
Text(text = name, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold)
|
||||
Icon(painterResource(R.drawable.settings), null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +233,7 @@ private fun WalletCard(
|
||||
accounts.forEach { account ->
|
||||
Surface(
|
||||
modifier = Modifier.padding(top = 8.dp).width(200.dp).clickable { onAccountClick(account) },
|
||||
color = Color.Gray,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -239,8 +241,8 @@ private fun WalletCard(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = account, color = Color.White, fontSize = 14.sp)
|
||||
Icon(painterResource(R.drawable.settings), null, tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(text = account, color = MaterialTheme.colorScheme.onSecondaryContainer, fontSize = 14.sp)
|
||||
Icon(painterResource(R.drawable.settings), null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,11 +289,11 @@ fun ExplorerScreen(
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text("Gaju Explorer Content", color = Color.Gray)
|
||||
Text("Gaju Explorer Content", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +359,7 @@ fun ManageWalletScreen(
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -366,8 +368,8 @@ fun ManageWalletScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(32.dp))
|
||||
Text(text = walletName.uppercase(), color = Color.Black, fontWeight = FontWeight.Bold)
|
||||
Icon(painterResource(R.drawable.settings), null, tint = Color.Black)
|
||||
Text(text = walletName.uppercase(), color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold)
|
||||
Icon(painterResource(R.drawable.settings), null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,13 +441,13 @@ fun ManageAccountScreen(
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.Transparent,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Text(
|
||||
text = walletName.uppercase(),
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -458,6 +460,7 @@ fun ManageAccountScreen(
|
||||
text = accountName,
|
||||
onClick = {},
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.width(200.dp),
|
||||
)
|
||||
Text("木 128,990,422.129,853", color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(top = 8.dp))
|
||||
@@ -489,8 +492,8 @@ fun DeleteConfirmationScreen(
|
||||
text = "CANCEL",
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
containerColor = Color.White,
|
||||
contentColor = Color.Black,
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
GajuButton(
|
||||
@@ -523,12 +526,12 @@ fun DeleteConfirmationScreen(
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = name.uppercase(),
|
||||
color = Color.Black,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -544,7 +547,7 @@ fun DeleteConfirmationScreen(
|
||||
|
||||
Text(
|
||||
text = "WARNING: Deleting a $type will delete all accounts within it.",
|
||||
color = Color.Yellow,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -581,17 +584,18 @@ fun DeleteConfirmationScreen(
|
||||
|
||||
@Composable
|
||||
private fun ManageAction(label: String, icon: Int, isDestructive: Boolean = false, onClick: () -> Unit = {}) {
|
||||
val color = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onClick() }) {
|
||||
Icon(
|
||||
painterResource(icon),
|
||||
null,
|
||||
tint = if (isDestructive) Color.Red else MaterialTheme.colorScheme.onSurface,
|
||||
tint = color,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isDestructive) Color.Red else MaterialTheme.colorScheme.onSurface,
|
||||
color = color,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package swiss.qpq.gajumobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import swiss.qpq.gajumobile.R
|
||||
import swiss.qpq.gajumobile.ui.components.GajuButton
|
||||
import swiss.qpq.gajumobile.ui.components.GajuTextField
|
||||
import swiss.qpq.gajumobile.ui.components.GajuHeader
|
||||
import swiss.qpq.gajumobile.security.AccountAirlock
|
||||
|
||||
@Composable
|
||||
fun CreateWalletScreen(
|
||||
onWalletCreated: (String) -> Unit
|
||||
) {
|
||||
var walletName by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(24.dp)
|
||||
.safeDrawingPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
GajuHeader()
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"CREATE WALLET",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"A wallet is a collection of accounts, like a folder for your identities.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
GajuTextField(
|
||||
value = walletName,
|
||||
onValueChange = { walletName = it },
|
||||
label = "WALLET NAME",
|
||||
placeholder = "e.g. Personal, Savings"
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
GajuButton(
|
||||
text = "CONTINUE",
|
||||
enabled = walletName.isNotBlank(),
|
||||
onClick = { onWalletCreated(walletName) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetupChoiceScreen(
|
||||
onCreate: () -> Unit,
|
||||
onRecover: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(24.dp)
|
||||
.safeDrawingPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.gajumobile_icon_colored),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(100.dp), // Slightly smaller to avoid cut-off in some layouts
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
GajuButton(
|
||||
text = "CREATE ACCOUNT",
|
||||
onClick = onCreate,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
GajuButton(
|
||||
text = "RECOVER ACCOUNT",
|
||||
onClick = onRecover,
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "To recover an existing account, you will need your mnemonic phrase.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MnemonicRecoveryScreen(
|
||||
onMnemonicConfirmed: (Array<ByteArray>) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var words by remember { mutableStateOf(listOf("")) }
|
||||
val allWords = remember { AccountAirlock.getWordList().toList() }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
GajuButton(
|
||||
text = "BACK",
|
||||
onClick = onBack,
|
||||
modifier = Modifier.weight(1f),
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
GajuButton(
|
||||
text = "RECOVER",
|
||||
enabled = words.size >= 21 && words.all { it.isNotBlank() },
|
||||
onClick = {
|
||||
val phrase = words.map { it.toByteArray(Charsets.UTF_8) }.toTypedArray()
|
||||
onMnemonicConfirmed(phrase)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
GajuHeader()
|
||||
Text(
|
||||
"RECOVER ACCOUNT",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(words.size) { index ->
|
||||
WordInputField(
|
||||
word = words[index],
|
||||
onWordChange = { newWord ->
|
||||
val newList = words.toMutableList()
|
||||
newList[index] = newWord
|
||||
words = newList
|
||||
},
|
||||
suggestions = if (words[index].length >= 2) {
|
||||
allWords.filter { it.startsWith(words[index]) }.take(5)
|
||||
} else emptyList(),
|
||||
onSuggestionSelected = { selected ->
|
||||
val newList = words.toMutableList()
|
||||
newList[index] = selected
|
||||
if (index == words.size - 1 && words.size < 23) {
|
||||
newList.add("")
|
||||
}
|
||||
words = newList
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
if (words.size < 23) {
|
||||
TextButton(onClick = { words = words + "" }) {
|
||||
Text("+ Add Word", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WordInputField(
|
||||
word: String,
|
||||
onWordChange: (String) -> Unit,
|
||||
suggestions: List<String>,
|
||||
onSuggestionSelected: (String) -> Unit
|
||||
) {
|
||||
Column {
|
||||
GajuTextField(
|
||||
value = word,
|
||||
onValueChange = onWordChange,
|
||||
label = "WORD",
|
||||
placeholder = "Type word..."
|
||||
)
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
suggestions.forEach { suggestion ->
|
||||
SuggestionChip(
|
||||
onClick = { onSuggestionSelected(suggestion) },
|
||||
label = { Text(suggestion) },
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountNamingScreen(
|
||||
publicKey: String,
|
||||
onAccountNamed: (String) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp).safeDrawingPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
GajuHeader()
|
||||
Text(
|
||||
"NAME YOUR ACCOUNT",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
"Account ID:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
publicKey,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
GajuTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = "ACCOUNT NAME",
|
||||
placeholder = "Optional (defaults to ID)"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
GajuButton(
|
||||
text = "FINISH",
|
||||
onClick = { onAccountNamed(name.ifBlank { publicKey }) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ fun QRScannerScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(280.dp)
|
||||
.border(2.dp, Color.White, RoundedCornerShape(16.dp)),
|
||||
.border(2.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(16.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -192,12 +192,12 @@ fun QRReceiveScreen(
|
||||
// QR Code Placeholder
|
||||
Surface(
|
||||
modifier = Modifier.size(240.dp),
|
||||
color = Color.White,
|
||||
color = Color.White, // QR Codes are usually black on white for scanning
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_favorite), // Replace with actual QR
|
||||
painter = painterResource(id = R.drawable.ic_favorite),
|
||||
contentDescription = "QR Code",
|
||||
modifier = Modifier.size(180.dp),
|
||||
tint = Color.Black,
|
||||
@@ -278,7 +278,7 @@ fun SendFormScreen(
|
||||
Text("SENDING FROM", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("DAILY", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
Icon(painterResource(R.drawable.ic_favorite), null, tint = Color.Gray) // Dropdown arrow
|
||||
Icon(painterResource(R.drawable.ic_favorite), null, tint = MaterialTheme.colorScheme.onSurfaceVariant) // Dropdown arrow
|
||||
}
|
||||
Text(
|
||||
text = "PRIMARY WALLET",
|
||||
@@ -314,7 +314,7 @@ fun SendFormScreen(
|
||||
Text("CURRENCY", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("木 Gajus", color = MaterialTheme.colorScheme.onBackground)
|
||||
Icon(painterResource(R.drawable.ic_favorite), null, tint = Color.Gray)
|
||||
Icon(painterResource(R.drawable.ic_favorite), null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,7 +361,8 @@ fun SendReviewScreen(
|
||||
text = "SEND",
|
||||
onClick = onSend,
|
||||
modifier = Modifier.weight(1f),
|
||||
containerColor = if (confirmed) MaterialTheme.colorScheme.primary else Color.LightGray,
|
||||
containerColor = if (confirmed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (confirmed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package swiss.qpq.gajumobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -16,7 +15,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
@@ -51,59 +49,6 @@ import swiss.qpq.gajumobile.ui.components.MnemonicReveal
|
||||
import swiss.qpq.gajumobile.ui.components.SuccessLayout
|
||||
import swiss.qpq.gajumobile.ui.components.WarningBox
|
||||
|
||||
@Composable
|
||||
fun SetupChoiceScreen(
|
||||
onCreate: () -> Unit,
|
||||
onRecover: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.safeDrawingPadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(32.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.gajumobile_icon_colored),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(120.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
GajuButton(
|
||||
text = "CREATE\nACCOUNT",
|
||||
onClick = onCreate,
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
GajuButton(
|
||||
text = "RECOVER\nEXISTING ACCOUNT",
|
||||
onClick = onRecover,
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "To recover and existing account, you will need your mnemonic phrase. You can add existing accounts later if you don't have access to your mnemonic phrase at the moment.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateAccountMnemonicScreen(
|
||||
onNext: () -> Unit,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="splash_background">#1C1B1F</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.GajuMobile" parent="android:Theme.Material.NoActionBar" />
|
||||
|
||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/gajumobile_icon_colored</item>
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.GajuMobile</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -7,4 +7,6 @@
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<color name="splash_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -4,8 +4,8 @@
|
||||
<style name="Theme.GajuMobile" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/gajumobile_logo_colored</item>
|
||||
<item name="windowSplashScreenBackground">@android:color/white</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/gajumobile_icon_colored</item>
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.GajuMobile</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,69 @@
|
||||
package swiss.qpq.gajumobile.security
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import swiss.qpq.gajumaru.core.crypto.Ed25519
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
class AccountAirlockTest {
|
||||
|
||||
@Test
|
||||
fun testShortMnemonicRoundtrip() {
|
||||
// Create a "short" seed (first 4 bytes are zero)
|
||||
val seed = ByteArray(32)
|
||||
seed[0] = 0
|
||||
seed[1] = 0
|
||||
seed[2] = 0
|
||||
seed[3] = 0
|
||||
seed[4] = 0xFF.toByte() // Some data after zeros
|
||||
|
||||
val phrase = swiss.qpq.gajumaru.core.encoding.Mnemonic.encode(seed)
|
||||
|
||||
// A full 32-byte seed usually gives 23 words (1 checksum + 22 data).
|
||||
// With 4 leading zero bytes, we drop at least 2 chunks (32 bits / 12 bits = 2.66).
|
||||
assertTrue("Phrase should be shorter than 23 words", phrase.size < 23)
|
||||
|
||||
val decoded = swiss.qpq.gajumaru.core.encoding.Mnemonic.decode(phrase)
|
||||
assertTrue("Decoded seed must match original short seed", seed.contentEquals(decoded))
|
||||
|
||||
AccountAirlock.wipePhrase(phrase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMnemonicRoundtrip() {
|
||||
// Let's test the roundtrip
|
||||
val entropy = ByteArray(32).apply { SecureRandom().nextBytes(this) }
|
||||
val phrase = swiss.qpq.gajumaru.core.encoding.Mnemonic.encode(entropy)
|
||||
val decoded = swiss.qpq.gajumaru.core.encoding.Mnemonic.decode(phrase)
|
||||
|
||||
assertTrue(entropy.contentEquals(decoded))
|
||||
AccountAirlock.wipePhrase(phrase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateAccountFlow() {
|
||||
val keyGen = KeyGenerator.getInstance("AES")
|
||||
keyGen.init(256)
|
||||
val masterKey: SecretKey = keyGen.generateKey()
|
||||
|
||||
val phrase = AccountAirlock.generateMnemonic()
|
||||
val account = AccountAirlock.createAccount("My Account", phrase, masterKey)
|
||||
|
||||
assertNotNull(account)
|
||||
assertEquals("My Account", account.label)
|
||||
assertNotNull(account.publicKey)
|
||||
assertNotNull(account.privateKeyEnvelope)
|
||||
|
||||
// Verify we can sign with this account
|
||||
val message = "Test sign".toByteArray()
|
||||
val signature = AccountAirlock.sign(account.privateKeyEnvelope, masterKey, message)
|
||||
|
||||
assertNotNull(signature)
|
||||
assertTrue(Ed25519.verify(account.publicKey, message, signature))
|
||||
AccountAirlock.wipePhrase(phrase)
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule gm-java added at 7493b473a7
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user