This commit is contained in:
2026-08-18 20:50:34 +09:00
parent dea88084b7
commit 8fd6d0a8a4
26 changed files with 691 additions and 206 deletions
+1
View File
@@ -3,6 +3,7 @@
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="a82de12b-d0aa-44d8-b9e8-e753cde67ff4" value="false" />
<entry key="b3cb996b-7f1f-4810-999d-5be96c526f74" value="false" />
</map>
</option>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+5
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -46,7 +47,11 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.biometric)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -6,7 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Assert.assertEquals
/**
* Instrumented test, which will execute on an Android device.
@@ -0,0 +1,47 @@
package swiss.qpq.gajumobile
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import swiss.qpq.gajumobile.data.WalletRepository
import swiss.qpq.gajumobile.data.models.Account
import swiss.qpq.gajumobile.data.models.Wallet
@RunWith(AndroidJUnit4::class)
class WalletSecurityTest {
@Test
fun testWalletEncryptionAndPersistence() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val repository = WalletRepository(appContext)
val testAccount = Account(
id = "test_acc_1",
label = "My Test Account",
publicKey = "pub_key_base64",
encryptedPrivateKey = "enc_priv_key_base64"
)
val testWallet = Wallet(
id = "test_wallet_1",
name = "Test Wallet",
accounts = listOf(testAccount)
)
// 1. Save the wallet
repository.saveWallet(testWallet)
// 2. Load the wallet
val loadedWallet = repository.loadWallet("test_wallet_1")
// 3. Verify
assertNotNull(loadedWallet)
assertEquals(testWallet.id, loadedWallet?.id)
assertEquals(testWallet.name, loadedWallet?.name)
assertEquals(1, loadedWallet?.accounts?.size)
assertEquals("My Test Account", loadedWallet?.accounts?.first()?.label)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.GajuMobile"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -1,31 +1,88 @@
package swiss.qpq.gajumobile
import android.os.Bundle
import androidx.activity.ComponentActivity
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.*
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 swiss.qpq.gajumobile.ui.screens.*
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ProcessLifecycleOwner
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.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.SetupChoiceScreen
import swiss.qpq.gajumobile.ui.screens.SetupSuccessScreen
import swiss.qpq.gajumobile.ui.screens.TransactionSuccessScreen
import swiss.qpq.gajumobile.ui.screens.SettingsScreen
import swiss.qpq.gajumobile.ui.theme.MyApplicationTheme
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
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 isLocked by SecurityManager.isLocked.collectAsState()
var lockErrorMessage by remember { mutableStateOf<String?>(null) }
MyApplicationTheme {
GajuRouter()
if (isLocked) {
LockScreen(
errorMessage = lockErrorMessage,
onUnlockRequest = {
BiometricHelper.showBiometricPrompt(
activity = this,
onSuccess = {
android.util.Log.d("GajuSecurity", "Unlock success")
lockErrorMessage = null
SecurityManager.unlock()
},
onError = { err ->
android.util.Log.e("GajuSecurity", "Unlock error: $err")
lockErrorMessage = err
}
)
}
)
} else {
GajuRouter()
}
}
}
}
}
enum class Screen {
SPLASH,
ONBOARDING_CONTINUE,
BIOMETRICS_PROMPT,
SETUP_CHOICE,
CREATE_MNEMONIC,
MNEMONIC_GRID,
@@ -48,7 +105,7 @@ enum class Screen {
@Composable
fun GajuRouter() {
var currentScreen by rememberSaveable { mutableStateOf(value = Screen.SPLASH) }
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") }
@@ -56,17 +113,6 @@ fun GajuRouter() {
var deleteName by rememberSaveable { mutableStateOf(value = "") }
when (currentScreen) {
Screen.SPLASH -> {
SplashScreen { currentScreen = Screen.ONBOARDING_CONTINUE }
}
Screen.ONBOARDING_CONTINUE -> {
WelcomeContinueScreen { currentScreen = Screen.BIOMETRICS_PROMPT }
}
Screen.BIOMETRICS_PROMPT -> {
BiometricsPromptScreen(
onYes = { currentScreen = Screen.SETUP_CHOICE },
) { currentScreen = Screen.SETUP_CHOICE }
}
Screen.SETUP_CHOICE -> {
SetupChoiceScreen(
onCreate = {
@@ -0,0 +1,43 @@
package swiss.qpq.gajumobile.data
import android.content.Context
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import swiss.qpq.gajumobile.data.models.Wallet
import swiss.qpq.gajumobile.security.EncryptionService
import java.io.File
class WalletRepository(private val context: Context) {
private val json = Json { prettyPrint = true }
fun saveWallet(wallet: Wallet) {
val walletJson = json.encodeToString(wallet)
val envelope = EncryptionService.encrypt(context, walletJson)
val envelopeJson = json.encodeToString(envelope)
val file = getWalletFile(wallet.id)
file.writeText(envelopeJson)
}
fun loadWallet(walletId: String): Wallet? {
val file = getWalletFile(walletId)
if (!file.exists()) return null
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)
} catch (e: Exception) {
android.util.Log.e("WalletRepository", "Failed to load wallet", e)
null
}
}
private fun getWalletFile(walletId: String): File {
val dir = File(context.filesDir, "wallets")
if (!dir.exists()) dir.mkdirs()
return File(dir, "wallet_$walletId.gaju")
}
}
@@ -0,0 +1,20 @@
package swiss.qpq.gajumobile.data.models
import kotlinx.serialization.Serializable
@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 cachedBalance: CachedBalance? = null
)
@Serializable
data class CachedBalance(
val amount: String,
val pucks: String, // Fractional part
val chainId: String,
val timestamp: Long
)
@@ -0,0 +1,11 @@
package swiss.qpq.gajumobile.data.models
import kotlinx.serialization.Serializable
@Serializable
data class Wallet(
val id: String,
val name: String,
val accounts: List<Account> = emptyList(),
val createdAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,31 @@
package swiss.qpq.gajumobile.security
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class AppLifecycleObserver(private val context: Context) : DefaultLifecycleObserver {
private val screenOffReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_SCREEN_OFF) {
SecurityManager.lock()
}
}
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
SecurityManager.onAppForegrounded()
context.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF))
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
SecurityManager.onAppBackgrounded()
context.unregisterReceiver(screenOffReceiver)
}
}
@@ -0,0 +1,63 @@
package swiss.qpq.gajumobile.security
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
object BiometricHelper {
fun showBiometricPrompt(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val biometricManager = BiometricManager.from(activity)
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
when (biometricManager.canAuthenticate(authenticators)) {
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("No biometrics or PIN enrolled on this device. Please set up security in system settings.")
return
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
// If no hardware, we might still be able to use DEVICE_CREDENTIAL
if (biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) != BiometricManager.BIOMETRIC_SUCCESS) {
onError("Device security is not available or not set up.")
return
}
}
BiometricManager.BIOMETRIC_SUCCESS -> { /* Proceed */ }
else -> {
// Other errors, let the prompt try anyway or handle specifically
}
}
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
onError(errString.toString())
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onError("Authentication failed")
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock GajuMobile")
.setSubtitle("Authenticate to continue")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build()
biometricPrompt.authenticate(promptInfo)
}
}
@@ -0,0 +1,77 @@
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
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
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
)
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 {
// 1. Generate a random Data Encryption Key (DEK) in software
val dek = generateDek()
// 2. Encrypt the data with the DEK
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())
// 3. Encrypt the DEK with the Master Key (Hardware-backed)
val masterKey = KeyManager.getMasterKey(context)
val dekCipher = Cipher.getInstance(AES_GCM)
// Let Keystore generate the IV for the Master Key encryption
dekCipher.init(Cipher.ENCRYPT_MODE, masterKey)
val encryptedDek = dekCipher.doFinal(dek.encoded)
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)
)
}
fun decrypt(context: Context, envelope: EncryptedEnvelope): String {
// 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))
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)
}
private fun generateDek(): SecretKey {
val keyGen = KeyGenerator.getInstance("AES")
keyGen.init(256)
return keyGen.generateKey()
}
}
@@ -0,0 +1,51 @@
package swiss.qpq.gajumobile.security
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
object KeyManager {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val MASTER_KEY_ALIAS = "GajuMasterKey"
fun getMasterKey(context: Context): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
return if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
keyStore.getKey(MASTER_KEY_ALIAS, null) as SecretKey
} else {
generateMasterKey(context)
}
}
private fun generateMasterKey(context: Context): SecretKey {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)
val builder = KeyGenParameterSpec.Builder(
MASTER_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
// Target StrongBox if available (Android 9+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val hasStrongBox = context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
if (hasStrongBox) {
builder.setIsStrongBoxBacked(true)
}
}
keyGenerator.init(builder.build())
return keyGenerator.generateKey()
}
}
@@ -0,0 +1,32 @@
package swiss.qpq.gajumobile.security
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
object SecurityManager {
private val _isLocked = MutableStateFlow(true)
val isLocked = _isLocked.asStateFlow()
private var lastBackgroundTime: Long = 0
private const val LOCK_TIMEOUT_MS = 2 * 60 * 1000L // 2 minutes
fun lock() {
_isLocked.value = true
}
fun unlock() {
_isLocked.value = false
}
fun onAppBackgrounded() {
lastBackgroundTime = System.currentTimeMillis()
}
fun onAppForegrounded() {
if (lastBackgroundTime != 0L && System.currentTimeMillis() - lastBackgroundTime > LOCK_TIMEOUT_MS) {
lock()
}
// Reset background time after checking
lastBackgroundTime = 0
}
}
@@ -2,11 +2,39 @@ package swiss.qpq.gajumobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.draw.clip
@@ -1,10 +1,30 @@
package swiss.qpq.gajumobile.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -0,0 +1,76 @@
package swiss.qpq.gajumobile.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import swiss.qpq.gajumobile.R
@Composable
fun LockScreen(
errorMessage: String? = null,
onUnlockRequest: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.safeDrawingPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.gajumobile_logo_colored),
contentDescription = null,
modifier = Modifier.size(150.dp),
)
Spacer(modifier = Modifier.height(48.dp))
Text(
text = "App Locked",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Please authenticate to continue.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
fontSize = 16.sp,
)
if (errorMessage != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
Spacer(modifier = Modifier.height(48.dp))
Button(onClick = onUnlockRequest) {
Text("Unlock")
}
}
}
}
@@ -2,13 +2,39 @@ package swiss.qpq.gajumobile.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
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.graphics.Color
@@ -18,7 +44,9 @@ 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.*
import swiss.qpq.gajumobile.ui.components.GajuBottomNavigation
import swiss.qpq.gajumobile.ui.components.GajuButton
import swiss.qpq.gajumobile.ui.components.GajuHeader
@Composable
fun SettingsScreen(
@@ -1,116 +0,0 @@
package swiss.qpq.gajumobile.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
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
@Composable
fun WelcomeContinueScreen(onContinue: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Surface(
modifier = Modifier.size(180.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(40.dp),
color = Color.Transparent,
border = androidx.compose.foundation.BorderStroke(1.dp, Color.Gray),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.ic_account_box), // Replace with actual alien icon if available
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
Spacer(modifier = Modifier.height(64.dp))
GajuButton(
text = "CONTINUE",
onClick = onContinue,
modifier = Modifier.width(200.dp),
)
}
}
}
@Composable
fun BiometricsPromptScreen(
onYes: () -> Unit,
onNo: () -> 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(100.dp),
)
Spacer(modifier = Modifier.height(48.dp))
Text(
text = "Would you like to use your phone biometrics for logging in to this app?",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Don't worry. You can still change this in your settings later.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(64.dp))
Row(modifier = Modifier.fillMaxWidth()) {
GajuButton(
text = "NO",
onClick = onNo,
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
)
Spacer(modifier = Modifier.width(16.dp))
GajuButton(
text = "YES",
onClick = onYes,
modifier = Modifier.weight(1f),
)
}
}
}
}
@@ -1,50 +0,0 @@
package swiss.qpq.gajumobile.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import swiss.qpq.gajumobile.R
import kotlin.time.Duration.Companion.seconds
@Composable
fun SplashScreen(onTimeout: () -> Unit) {
LaunchedEffect(Unit) {
delay(2.seconds)
onTimeout()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.safeDrawingPadding(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(id = R.drawable.gajumobile_logo_colored),
contentDescription = null,
modifier = Modifier.size(200.dp),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Welcome to smarter, safer finance.",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@@ -3,12 +3,36 @@ package swiss.qpq.gajumobile.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.graphics.Color
@@ -18,7 +42,10 @@ 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.*
import swiss.qpq.gajumobile.ui.components.GajuButton
import swiss.qpq.gajumobile.ui.components.GajuHeader
import swiss.qpq.gajumobile.ui.components.GajuTextField
import swiss.qpq.gajumobile.ui.components.SuccessLayout
@Composable
fun QRScannerScreen(
@@ -3,15 +3,39 @@ 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.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.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
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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
+6
View File
@@ -2,4 +2,10 @@
<resources>
<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="postSplashScreenTheme">@style/Theme.GajuMobile</item>
</style>
</resources>
@@ -2,7 +2,7 @@ package swiss.qpq.gajumobile
import org.junit.Test
import org.junit.Assert.*
import org.junit.Assert.assertEquals
/**
* Example local unit test, which will execute on the development machine (host).
+9
View File
@@ -8,6 +8,10 @@ lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2025.12.00"
coreSplashScreen = "1.2.0"
biometric = "1.1.0"
lifecycleProcess = "2.6.1"
kotlinxSerialization = "1.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -25,8 +29,13 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashScreen" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }