diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml index b5ff7b5..f09e804 100644 --- a/.idea/planningMode.xml +++ b/.idea/planningMode.xml @@ -3,6 +3,7 @@ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd25772..9d98493 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/androidTest/java/swiss/qpq/gajumobile/ExampleInstrumentedTest.kt b/app/src/androidTest/java/swiss/qpq/gajumobile/ExampleInstrumentedTest.kt index 25dfabc..92a3622 100644 --- a/app/src/androidTest/java/swiss/qpq/gajumobile/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/swiss/qpq/gajumobile/ExampleInstrumentedTest.kt @@ -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. diff --git a/app/src/androidTest/java/swiss/qpq/gajumobile/WalletSecurityTest.kt b/app/src/androidTest/java/swiss/qpq/gajumobile/WalletSecurityTest.kt new file mode 100644 index 0000000..dbc2c3b --- /dev/null +++ b/app/src/androidTest/java/swiss/qpq/gajumobile/WalletSecurityTest.kt @@ -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) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7551a7d..ba28363 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt b/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt index e2a3071..7379f86 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/MainActivity.kt @@ -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(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 = { diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt b/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt new file mode 100644 index 0000000..547fb37 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/data/WalletRepository.kt @@ -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(envelopeJson) + val walletJson = EncryptionService.decrypt(context, envelope) + json.decodeFromString(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") + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt b/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt new file mode 100644 index 0000000..f032a46 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/data/models/Account.kt @@ -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 +) diff --git a/app/src/main/java/swiss/qpq/gajumobile/data/models/Wallet.kt b/app/src/main/java/swiss/qpq/gajumobile/data/models/Wallet.kt new file mode 100644 index 0000000..925c8b0 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/data/models/Wallet.kt @@ -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 = emptyList(), + val createdAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt b/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt new file mode 100644 index 0000000..0a7a85e --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/security/AppLifecycleObserver.kt @@ -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) + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/BiometricHelper.kt b/app/src/main/java/swiss/qpq/gajumobile/security/BiometricHelper.kt new file mode 100644 index 0000000..e539c2f --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/security/BiometricHelper.kt @@ -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) + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt new file mode 100644 index 0000000..bb24d52 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/security/EncryptionService.kt @@ -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() + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt new file mode 100644 index 0000000..26c48f7 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/security/KeyManager.kt @@ -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() + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/security/SecurityManager.kt b/app/src/main/java/swiss/qpq/gajumobile/security/SecurityManager.kt new file mode 100644 index 0000000..acdd24c --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/security/SecurityManager.kt @@ -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 + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt index 2ce910c..a97f8e6 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/components/GajuComponents.kt @@ -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 diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt index bebd4bd..527537b 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/DashboardScreen.kt @@ -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 diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/LockScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/LockScreen.kt new file mode 100644 index 0000000..26e0a27 --- /dev/null +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/LockScreen.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt index 7d54076..905f5ac 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/ManagementScreens.kt @@ -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( diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt deleted file mode 100644 index 71e8b37..0000000 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/OnboardingFlow.kt +++ /dev/null @@ -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), - ) - } - } - } -} diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SplashScreen.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SplashScreen.kt deleted file mode 100644 index 06f2a0f..0000000 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/SplashScreen.kt +++ /dev/null @@ -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, - ) - } - } -} diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt index f2b838d..c210c75 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/TransactionScreens.kt @@ -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( diff --git a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt index 50d6632..57eb1f8 100644 --- a/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt +++ b/app/src/main/java/swiss/qpq/gajumobile/ui/screens/WalletSetupScreens.kt @@ -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 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index cd6a3ab..1e05327 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,4 +2,10 @@ \ No newline at end of file diff --git a/app/src/test/java/swiss/qpq/gajumobile/ExampleUnitTest.kt b/app/src/test/java/swiss/qpq/gajumobile/ExampleUnitTest.kt index 528268d..2230737 100644 --- a/app/src/test/java/swiss/qpq/gajumobile/ExampleUnitTest.kt +++ b/app/src/test/java/swiss/qpq/gajumobile/ExampleUnitTest.kt @@ -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). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7211717..3aa37bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }