WIP
This commit is contained in:
Generated
+1
@@ -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
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user