diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..36a4f21
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "gm-java"]
+ path = gm-java
+ url = ssh://gitea@git.qpq.swiss:21203/QPQ-AG/gm-java.git
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index ee21552..0000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-GajuMobile
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
deleted file mode 100644
index 4a53bee..0000000
--- a/.idea/AndroidProjectSystem.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b86273d..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index b49fb06..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
deleted file mode 100644
index 91f9558..0000000
--- a/.idea/deviceManager.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 02c4aa5..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 7061a0d..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index a599984..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml
deleted file mode 100644
index 914cb73..0000000
--- a/.idea/planningMode.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1..0000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/TestGajuFormat.class b/TestGajuFormat.class
deleted file mode 100644
index 3b5d62c..0000000
Binary files a/TestGajuFormat.class and /dev/null differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4244ccb..e888828 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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 {
diff --git a/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt b/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt
index 297ad0e..2a2a94c 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt
@@ -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(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(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")
}
}
}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/AppState.kt b/app/src/main/java/swiss/qpq/gajumobile/data/AppState.kt
new file mode 100644
index 0000000..2251d4c
--- /dev/null
+++ b/app/src/main/java/swiss/qpq/gajumobile/data/AppState.kt
@@ -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 = 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))
+ }
+}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt b/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt
index 547fb37..ee74f9e 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt
@@ -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(envelopeJson)
- val walletJson = EncryptionService.decrypt(context, envelope)
- json.decodeFromString(walletJson)
+ val walletBytes = EncryptionService.decrypt(context, envelope)
+ json.decodeFromString(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 {
+ 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()
diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt b/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt
index f032a46..ece3cab 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt
@@ -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(
diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java b/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java
new file mode 100644
index 0000000..32b03db
--- /dev/null
+++ b/app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2026 QPQ AG . 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);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt
index bb24d52..e411350 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt
@@ -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 {
diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt
index 26c48f7..0ffe9e4 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt
@@ -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) {
diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt
index a97f8e6..0479ee6 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt
@@ -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(),
)
}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt
index 905f5ac..e2164bf 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt
@@ -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,
)
}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt
new file mode 100644
index 0000000..9abfb52
--- /dev/null
+++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt
@@ -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) -> 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,
+ 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 }) }
+ )
+ }
+}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt
index c210c75..5b006ef 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt
@@ -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,
)
}
}
diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt
index 57eb1f8..518f138 100644
--- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt
+++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt
@@ -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,
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..6463bc5
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #1C1B1F
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..b282909
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..0fd8442 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,6 @@
#FF018786
#FF000000
#FFFFFFFF
+
+ #FFFFFF
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 1e05327..2a62078 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -4,8 +4,8 @@
\ No newline at end of file
diff --git a/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt b/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt
new file mode 100644
index 0000000..dcc7a15
--- /dev/null
+++ b/app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt
@@ -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)
+ }
+}
diff --git a/gm-java b/gm-java
new file mode 160000
index 0000000..7493b47
--- /dev/null
+++ b/gm-java
@@ -0,0 +1 @@
+Subproject commit 7493b473a7fa2bdf6c606fa8b45593794fde9afa
diff --git a/swiss/qpq/gajumaru/core/formatting/GajuFormat$FormatSpec.class b/swiss/qpq/gajumaru/core/formatting/GajuFormat$FormatSpec.class
deleted file mode 100644
index 0794a73..0000000
Binary files a/swiss/qpq/gajumaru/core/formatting/GajuFormat$FormatSpec.class and /dev/null differ
diff --git a/swiss/qpq/gajumaru/core/formatting/GajuFormat$Type.class b/swiss/qpq/gajumaru/core/formatting/GajuFormat$Type.class
deleted file mode 100644
index bc91fab..0000000
Binary files a/swiss/qpq/gajumaru/core/formatting/GajuFormat$Type.class and /dev/null differ
diff --git a/swiss/qpq/gajumaru/core/formatting/GajuFormat$Unit.class b/swiss/qpq/gajumaru/core/formatting/GajuFormat$Unit.class
deleted file mode 100644
index f355adc..0000000
Binary files a/swiss/qpq/gajumaru/core/formatting/GajuFormat$Unit.class and /dev/null differ
diff --git a/swiss/qpq/gajumaru/core/formatting/GajuFormat.class b/swiss/qpq/gajumaru/core/formatting/GajuFormat.class
deleted file mode 100644
index d46925c..0000000
Binary files a/swiss/qpq/gajumaru/core/formatting/GajuFormat.class and /dev/null differ