From d75c8407ca70a6ee6e6474960105c0963d219a30 Mon Sep 17 00:00:00 2001 From: Craig Everett Date: Fri, 21 Aug 2026 18:59:38 +0900 Subject: [PATCH] Very very basic account creation (but not doing anythin with it yet) using Vault --- .gitmodules | 3 + .idea/.gitignore | 3 - .idea/.name | 1 - .idea/AndroidProjectSystem.xml | 6 - .idea/compiler.xml | 6 - .idea/deploymentTargetSelector.xml | 23 -- .idea/deviceManager.xml | 13 - .idea/gradle.xml | 18 - .idea/inspectionProfiles/Project_Default.xml | 61 ---- .idea/misc.xml | 10 - .idea/planningMode.xml | 12 - .idea/runConfigurations.xml | 17 - .idea/vcs.xml | 6 - TestGajuFormat.class | Bin 2476 -> 0 bytes app/build.gradle.kts | 10 +- .../java/swiss/qpq/gajumobile/MainActivity.kt | 318 ++++++++---------- .../swiss/qpq/gajumobile/data/AppState.kt | 43 +++ .../qpq/gajumobile/data/WalletRepository.kt | 23 +- .../qpq/gajumobile/data/models/Account.kt | 31 +- .../gajumobile/security/AccountAirlock.java | 182 ++++++++++ .../gajumobile/security/EncryptionService.kt | 57 ++-- .../qpq/gajumobile/security/KeyManager.kt | 16 + .../ui/components/GajuComponents.kt | 6 +- .../ui/screens/ManagementScreens.kt | 50 +-- .../gajumobile/ui/screens/OnboardingFlow.kt | 299 ++++++++++++++++ .../ui/screens/TransactionScreens.kt | 13 +- .../ui/screens/WalletSetupScreens.kt | 55 --- app/src/main/res/values-night/colors.xml | 4 + app/src/main/res/values-night/themes.xml | 10 + app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/themes.xml | 4 +- .../gajumobile/security/AccountAirlockTest.kt | 69 ++++ gm-java | 1 + .../formatting/GajuFormat$FormatSpec.class | Bin 2132 -> 0 bytes .../core/formatting/GajuFormat$Type.class | Bin 1322 -> 0 bytes .../core/formatting/GajuFormat$Unit.class | Bin 1228 -> 0 bytes .../gajumaru/core/formatting/GajuFormat.class | Bin 9461 -> 0 bytes 37 files changed, 902 insertions(+), 470 deletions(-) create mode 100644 .gitmodules delete mode 100644 .idea/.gitignore delete mode 100644 .idea/.name delete mode 100644 .idea/AndroidProjectSystem.xml delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/deploymentTargetSelector.xml delete mode 100644 .idea/deviceManager.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/planningMode.xml delete mode 100644 .idea/runConfigurations.xml delete mode 100644 .idea/vcs.xml delete mode 100644 TestGajuFormat.class create mode 100644 app/src/main/java/swiss/qpq/gajumobile/data/AppState.kt create mode 100644 app/src/main/java/swiss/qpq/gajumobile/security/AccountAirlock.java create mode 100644 app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/test/java/swiss/qpq/gajumobile/security/AccountAirlockTest.kt create mode 160000 gm-java delete mode 100644 swiss/qpq/gajumaru/core/formatting/GajuFormat$FormatSpec.class delete mode 100644 swiss/qpq/gajumaru/core/formatting/GajuFormat$Type.class delete mode 100644 swiss/qpq/gajumaru/core/formatting/GajuFormat$Unit.class delete mode 100644 swiss/qpq/gajumaru/core/formatting/GajuFormat.class 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 3b5d62c8059e22f12a0bc78b467521b650945dcc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2476 zcmb_d+fy4=82=pz*$|gP143ghO_57dN<+A`kknd;g@$sIgjB@ak{ncf$&0w2Sey{FrDbf>fv-O#qm zMa?QlbEd_k>!wxI97iYbX;MtdzuoRDUE;Y$`0B6178`LDAqCe|bm29IHdzFe$<64bzL7EoX0`t7Uyui>9UY(3&{dfyY3eqYv zp#H5Gb8b?J<6W&#<_qi3WzL;~iK;+6h>1peTg8fKB=6k)%u*`p!z#liVt!=w*6p$J zifo9_07Vs3@IJ$Z47Z)LF-e$`W}XII^Q><0`EqfMTN!PwKoU<;(<%4u;ngZ7m1Xd! z&9ar_Q@Sv2J-_WuQBVYU(lj00v9!`0cQ(zu?ZXDyE~i5xf>1aXDwT8Fk$8ytpfd!X zDGHjk!Cm<_mEWm+LRL;FJnX8dz5O$baxb}sN!eYZ42Am6Ww1{wcL+O0Vd=S3hh{Hq zKOB@5?25+PW9XKRAlpticFk=bbra?G+LV@aOlzOv+sXnsF?10=(2RV6+ugIKxm_-u zba!PNS8#dGQCu@s%qzLCQWPg!B(*{ztvj6hQB4^Jw~__TwmHoM|JkUajJkK3M@(o1 zpEC5k0Jj3XO21NwJ`-i+Q2*AKydqz{G;;$ptNE z;rcNKfuAt)6jxUQ@k88+H%MKB)ZLXjSKvLu8^=hVN(Q9Io8%!ar=&bEeS}opOAbOC zq=@E@uy~B@$r&QpM=}%`I4%%S1s4f$7m+XwF_N2*qPsIg zQjRw4p#xvjHTn%M;|Fx&7n(D_Aw=^kN4|B)lNHcQq*+AZx?DHqx+T|wTn)LF@R)47 e;Gr}7v5z24l74)2FG%P0<70Y%g3s^;g8u;Gsi89f 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 0794a735e3b0ce8c110837845eba497711625499..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2132 zcmbtV+fvg|6kP`kDK%JHtqO{uqP9VZ;@x;bst8)a5#&LiLODX9O-hpL_)k9QjEplp z_yK;D<2p&Pb?7(~eMnY%&c3dF>5rd#-vKlj)RQRzW1+G_;nOLEgrJ^{ZBa1=WX)?Oxc3x8}vXj?w6vqVm1KF^B zJM?@3QC1#~>o|dv^bj;`mxl{^QZ}sP6iy4ILvK0snV-O7F<}mE8mv;KO?Yh2QeMvJ z7{wU9Y}&zQ*;^-3_y5OB6#6+G=P}NL%AKZN3j~(_!(>O?OKV%Q8d{|{0`;U8qjDys zFDhMmp|r}<#N1>njw=_N^)>0Q*lRViHTXh?o8J0MRV`crG2}{jDRzTx3z$eG2knO|$3?AU2h8dm9WL99b-Ohf~ z4V}6)9Gu| zzSH0kcdC?Yb`W$Pv~0XRXr$BoAH&&dashq8b)_GflKUpl&`=S$(5C+{bZS@>IP-T=c1g@Vu5uOja3`gZS4*E?d9`^X%?J} zF)m_U!nlBO`EKw&&p7U}9BJIdZQQ|KSSa)S9QD$)@tB-HAwHcM!aVUA@i|^#2}8dC D8|@qm 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 bc91fab6a78538474e8f76e3bda6a4d436d7d170..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1322 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 bi+5GrO=r7zk;zr^<=#c6?34TC0L;GuCPGL<