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