Very very basic account creation (but not doing anythin with it yet) using Vault

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