From d450ac3b8c3b9f84cbdd652d3056088795751eab Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Wed, 4 Mar 2026 22:24:17 +0100 Subject: [PATCH] Add syllable-based deterministic password vault --- app/build.gradle.kts | 29 +- app/src/main/AndroidManifest.xml | 3 +- .../cz/bugsy/passwordzebra/MainActivity.kt | 284 +----------------- .../DeterministicPasswordGenerator.kt | 113 +++++++ .../deterministic/DeterministicViewModel.kt | 150 +++++++++ .../deterministic/DeviceSecretManager.kt | 41 +++ .../deterministic/ServiceHistoryRepository.kt | 45 +++ .../ui/navigation/AppNavigation.kt | 125 ++++++++ .../ui/screens/DeterministicScreen.kt | 207 +++++++++++++ .../ui/screens/ExportImportScreen.kt | 247 +++++++++++++++ .../passwordzebra/ui/screens/LockScreen.kt | 97 ++++++ .../ui/screens/ServiceHistoryScreen.kt | 117 ++++++++ .../ui/screens/SettingsScreen.kt | 182 +++++++++++ .../ui/screens/WordGeneratorScreen.kt | 279 +++++++++++++++++ app/src/main/res/values-cs/strings.xml | 55 +++- app/src/main/res/values/strings.xml | 55 +++- 16 files changed, 1744 insertions(+), 285 deletions(-) create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicPasswordGenerator.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeviceSecretManager.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/screens/LockScreen.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/cz/bugsy/passwordzebra/ui/screens/WordGeneratorScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7252dd..9907b2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,12 +5,12 @@ plugins { android { namespace = "cz.bugsy.passwordzebra" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "cz.bugsy.passwordzebra" minSdk = 29 - targetSdk = 34 + targetSdk = 35 versionCode = 9 versionName = "1.7.0" @@ -39,6 +39,11 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.10" } + packaging { + resources { + excludes += "META-INF/versions/9/OSGI-INF/**" + } + } } dependencies { @@ -53,6 +58,26 @@ dependencies { implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + // Navigation + ViewModel + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + + // Cryptography – Argon2id via Bouncy Castle + implementation("org.bouncycastle:bcprov-jdk15on:1.70") + + // EncryptedSharedPreferences (Android Keystore) + implementation("androidx.security:security-crypto:1.0.0") + + // QR codes – generation + scanning + implementation("com.google.zxing:core:3.5.2") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + + // Biometrics + implementation("androidx.biometric:biometric:1.1.0") + + // Material Icons Extended + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index adedc07..8ea4ee6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ - + + - if (isLandscape) { - // Landscape: controls left (vertically centered), password + generate button right - Row( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Left: controls only, vertically centered - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ControlsPanel( - wordCount = wordCount, - withoutSpaces = withoutSpaces, - addSpecialChars = addSpecialChars, - onWordCountChange = { wordCount = it }, - onWithoutSpacesChange = { withoutSpaces = it }, - onAddSpecialCharsChange = { addSpecialChars = it }, - modifier = Modifier.fillMaxWidth(), - ) - } - // Right: password box fills remaining height, generate button pinned to bottom - Column( - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PasswordBox( - password = password, - onClick = { copyToClipboard(context, password, withoutSpaces) }, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) - Spacer(modifier = Modifier.height(12.dp)) - GenerateButton(onClick = onGenerate) - } - } - } else { - // Portrait: password box top, controls middle, generate button pinned to bottom - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.weight(1f)) - PasswordBox( - password = password, - onClick = { copyToClipboard(context, password, withoutSpaces) }, - modifier = Modifier - .fillMaxWidth() - .height(150.dp), - ) - Spacer(modifier = Modifier.weight(2f)) - ControlsPanel( - wordCount = wordCount, - withoutSpaces = withoutSpaces, - addSpecialChars = addSpecialChars, - onWordCountChange = { wordCount = it }, - onWithoutSpacesChange = { withoutSpaces = it }, - onAddSpecialCharsChange = { addSpecialChars = it }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.weight(3f)) - GenerateButton(onClick = onGenerate) - Spacer(modifier = Modifier.height(16.dp)) - } - } - } -} - -@Composable -private fun PasswordBox(password: String, onClick: () -> Unit, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(12.dp), - ) - .clickable { onClick() } - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = password, - fontSize = 24.sp, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} - -@Composable -private fun ControlsPanel( - wordCount: Int, - withoutSpaces: Boolean, - addSpecialChars: Boolean, - onWordCountChange: (Int) -> Unit, - onWithoutSpacesChange: (Boolean) -> Unit, - onAddSpecialCharsChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(R.drawable.pw_straighten), - contentDescription = stringResource(R.string.image_length_desc), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.textview_password_length), - modifier = Modifier.weight(1f), - ) - NumberPickerView( - value = wordCount, - minValue = 1, - maxValue = 10, - onValueChange = onWordCountChange, - modifier = Modifier.widthIn(min = 80.dp), - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(R.drawable.pw_letter_spacing), - contentDescription = stringResource(R.string.image_wospaces), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.switch_without_spaces), - modifier = Modifier.weight(1f), - ) - Switch( - checked = withoutSpaces, - onCheckedChange = onWithoutSpacesChange, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(R.drawable.pw_special_character), - contentDescription = stringResource(R.string.image_specialchars), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.switch_special_characters), - modifier = Modifier.weight(1f), - ) - Switch( - checked = addSpecialChars, - onCheckedChange = onAddSpecialCharsChange, - ) - } - } -} - -@Composable -private fun GenerateButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - FilledIconButton( - onClick = onClick, - modifier = modifier.size(60.dp), - shape = RoundedCornerShape(16.dp), - ) { - Icon( - painter = painterResource(R.drawable.pw_generate), - contentDescription = stringResource(R.string.button_generate_description), - ) - } -} - -private fun generatePassword(wordCount: Int, addSpecialChars: Boolean): String { - return buildString { - repeat(wordCount) { - append(PasswordGenerator.generateRandomWord()) - append(" ") - } - }.let { password -> - if (addSpecialChars) { - PasswordGenerator.addSpecialCharacters(password) - } else { - password - } - }.trim() -} - -private fun copyToClipboard(context: Context, text: String, withoutSpaces: Boolean) { - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipText = if (withoutSpaces) text.filter { !it.isWhitespace() } else text - val clipData = ClipData.newPlainText("Password", clipText) - clipboardManager.setPrimaryClip(clipData) -} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicPasswordGenerator.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicPasswordGenerator.kt new file mode 100644 index 0000000..951b94b --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicPasswordGenerator.kt @@ -0,0 +1,113 @@ +package cz.bugsy.passwordzebra.deterministic + +import android.util.Base64 +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters + +data class SyllableOptions( + val wordCount: Int = 5, + val addSpecialChars: Boolean = true, +) + +object DeterministicPasswordGenerator { + + private const val ARGON2_MEMORY = 65536 + private const val ARGON2_ITERATIONS = 3 + private const val ARGON2_PARALLELISM = 1 + private const val ARGON2_OUTPUT_LEN = 64 + + private val SPECIAL_CHARS = listOf('$', ',', '.', '#', '@', '!', '%', '&') + + // Same syllable corpus as PasswordGenerator (duplicates preserved for natural weighting) + private val syllables = arrayOf( + "ing","er","a","ly","ed","i","es","re","tion","in","e","con","y","ter","ex","al","de","com", + "o","di","en","an","ty","ry","u","ti","ri","be","per","to","pro","ac","ad","ar","ers","ment", + "or","tions","ble","der","ma","na","si","un","at","dis","ca","cal","man","ap","po","sion","vi", + "el","est","la","lar","pa","ture","for","is","mer","pe","ra","so","ta","as","col","fi","ful", + "ger","low","ni","par","son","tle","day","ny","pen","pre","tive","car","ci","mo","on","ous", + "pi","se","ten","tor","ver","ber","can","dy","et","it","mu","no","ple","cu","fac","fer","gen", + "ic","land","light","ob","of","pos","tain","den","ings","mag","ments","set","some","sub","sur", + "ters","tu","af","au","cy","fa","im","li","lo","men","min","mon","op","out","rec","ro","sen", + "side","tal","tic","ties","ward","age","ba","but","cit","cle","co","cov","da","dif","ence", + "ern","eve","hap","ies","ket","lec","main","mar","mis","my","nal","ness","ning","n't","nu","oc", + "pres","sup","te","ted","tem","tin","tri","tro","up","va","ven","vis","am","bor","by","cat", + "cent","ev","gan","gle","head","high","il","lu","me","nore","part","por","read","rep","su", + "tend","ther","ton","try","um","uer","way","ate","bet","bles","bod","cap","cial","cir","cor", + "coun","cus","dan","dle","ef","end","ent","ered","fin","form","go","har","ish","lands","let", + "long","mat","meas","mem","mul","ner","play","ples","ply","port","press","sat","sec","ser", + "south","sun","the","ting","tra","tures","val","var","vid","wil","win","won","work","act","ag", + "air","als","bat","bi","cate","cen","char","come","cul","ders","east","fect","fish","fix","gi", + "grand","great","heav","ho","hunt","ion","its","jo","lat","lead","lect","lent","less","lin", + "mal","mi","mil","moth","near","nel","net","new","one","point","prac","ral","rect","ried", + "round","row","sa","sand","self","sent","ship","sim","sions","sis","sons","stand","sug","tel", + "tom","tors","tract","tray","us","vel","west","where","writing","er","i","y","ter","al","ed", + "es","e","tion","re","o","oth","ry","de","ver","ex","en","di","bout","com","ple","u","con", + "per","un","der","tle","ber","ty","num","peo","ble","af","ers","mer","wa","ment","pro","ar", + "ma","ri","sen","ture","fer","dif","pa","tions","ther","fore","est","fa","la","ei","not","si", + "ent","ven","ev","ac","ca","fol","ful","na","tain","ning","col","par","dis","ern","ny","cit", + "po","cal","mu","moth","pic","im","coun","mon","pe","lar","por","fi","bers","sec","ap","stud", + "ad","tween","gan","bod","tence","ward","hap","nev","ure","mem","ters","cov","ger","nit" + ) + + fun generate( + masterPassword: CharArray, + deviceSecret: ByteArray, + serviceName: String, + counter: Int, + options: SyllableOptions, + ): String { + // Build effective master = masterPassword + Base64(deviceSecret) + val deviceSecretB64 = Base64.encodeToString(deviceSecret, Base64.NO_WRAP).toCharArray() + val effectiveMaster = CharArray(masterPassword.size + deviceSecretB64.size) + System.arraycopy(masterPassword, 0, effectiveMaster, 0, masterPassword.size) + System.arraycopy(deviceSecretB64, 0, effectiveMaster, masterPassword.size, deviceSecretB64.size) + + val salt = "$serviceName:$counter".toByteArray(Charsets.UTF_8) + val passwordBytes = String(effectiveMaster).toByteArray(Charsets.UTF_8) + + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withSalt(salt) + .withMemoryAsKB(ARGON2_MEMORY) + .withIterations(ARGON2_ITERATIONS) + .withParallelism(ARGON2_PARALLELISM) + .build() + + val output = ByteArray(ARGON2_OUTPUT_LEN) + val generator = Argon2BytesGenerator() + generator.init(params) + generator.generateBytes(passwordBytes, output) + + var cursor = 0 + fun nextByte(): Int = (output[cursor++ % ARGON2_OUTPUT_LEN].toInt() and 0xFF) + + // Build syllable-based words from Argon2id output bytes + val words = (0 until options.wordCount).map { + val syllableCount = 2 + (nextByte() % 2) // 2 or 3 syllables per word + buildString { + repeat(syllableCount) { + val idx = ((nextByte() shl 8) or nextByte()) % syllables.size + append(syllables[idx]) + } + } + } + + val sb = StringBuilder(words.joinToString(" ")) + + if (options.addSpecialChars) { + val specialChar = SPECIAL_CHARS[nextByte() % SPECIAL_CHARS.size] + val uppercaseChar = 'A' + (nextByte() % 26) + val digit = '0' + (nextByte() % 10) + sb.insert(nextByte() % (sb.length + 1), specialChar) + sb.insert(nextByte() % (sb.length + 1), uppercaseChar) + sb.insert(nextByte() % (sb.length + 1), digit) + } + + // Wipe sensitive data + effectiveMaster.fill('\u0000') + deviceSecretB64.fill('\u0000') + passwordBytes.fill(0) + output.fill(0) + + return sb.toString() + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt new file mode 100644 index 0000000..14a78b0 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeterministicViewModel.kt @@ -0,0 +1,150 @@ +package cz.bugsy.passwordzebra.deterministic + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class DeterministicState( + val serviceName: String = "", + val masterPassword: CharArray = CharArray(0), + val masterPasswordVisible: Boolean = false, + val counter: Int = 1, + val generatedPassword: String = "", + val isLocked: Boolean = false, + val clipboardTimerSeconds: Int = 0, + val biometricEnabled: Boolean = false, + val lockTimeoutMinutes: Int = 5, +) { + // CharArray doesn't support structural equality; override manually + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DeterministicState) return false + return serviceName == other.serviceName && + masterPassword.contentEquals(other.masterPassword) && + masterPasswordVisible == other.masterPasswordVisible && + counter == other.counter && + generatedPassword == other.generatedPassword && + isLocked == other.isLocked && + clipboardTimerSeconds == other.clipboardTimerSeconds && + biometricEnabled == other.biometricEnabled && + lockTimeoutMinutes == other.lockTimeoutMinutes + } + + override fun hashCode(): Int { + var result = serviceName.hashCode() + result = 31 * result + masterPassword.contentHashCode() + result = 31 * result + masterPasswordVisible.hashCode() + result = 31 * result + counter + result = 31 * result + generatedPassword.hashCode() + result = 31 * result + isLocked.hashCode() + result = 31 * result + clipboardTimerSeconds + result = 31 * result + biometricEnabled.hashCode() + result = 31 * result + lockTimeoutMinutes + return result + } +} + +class DeterministicViewModel(app: Application) : AndroidViewModel(app) { + + private val deviceSecretManager = DeviceSecretManager(app) + val historyRepository = ServiceHistoryRepository(app) + + private val _state = MutableStateFlow(DeterministicState()) + val state: StateFlow = _state.asStateFlow() + + private val settingsPrefs = app.getSharedPreferences("det_settings", Context.MODE_PRIVATE) + + private var clipboardClearJob: Job? = null + + init { + _state.update { + it.copy( + biometricEnabled = settingsPrefs.getBoolean("biometric_enabled", false), + lockTimeoutMinutes = settingsPrefs.getInt("lock_timeout_minutes", 5), + ) + } + } + + fun updateServiceName(name: String) = _state.update { it.copy(serviceName = name) } + + fun updateMasterPassword(pw: CharArray) = _state.update { it.copy(masterPassword = pw) } + + fun toggleMasterPasswordVisible() = + _state.update { it.copy(masterPasswordVisible = !it.masterPasswordVisible) } + + fun incrementCounter() = _state.update { it.copy(counter = it.counter + 1) } + + fun decrementCounter() = _state.update { if (it.counter > 1) it.copy(counter = it.counter - 1) else it } + + fun selectService(name: String, counter: Int) = + _state.update { it.copy(serviceName = name, counter = counter) } + + fun generate() { + val s = _state.value + if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return + val deviceSecret = deviceSecretManager.getOrCreateSecret() + val password = DeterministicPasswordGenerator.generate( + masterPassword = s.masterPassword, + deviceSecret = deviceSecret, + serviceName = s.serviceName, + counter = s.counter, + options = SyllableOptions(), + ) + historyRepository.upsert(s.serviceName, s.counter) + _state.update { it.copy(generatedPassword = password) } + } + + fun copyAndScheduleClear(context: Context) { + val password = _state.value.generatedPassword + if (password.isEmpty()) return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Password", password)) + + clipboardClearJob?.cancel() + _state.update { it.copy(clipboardTimerSeconds = 30) } + + clipboardClearJob = viewModelScope.launch { + repeat(30) { + delay(1000) + _state.update { it.copy(clipboardTimerSeconds = it.clipboardTimerSeconds - 1) } + } + clipboard.setPrimaryClip(ClipData.newPlainText("", "")) + _state.update { it.copy(clipboardTimerSeconds = 0) } + } + } + + fun lock() = _state.update { it.copy(isLocked = true) } + + fun unlock() = _state.update { it.copy(isLocked = false) } + + fun onAppForeground(lastBackgroundTimeMs: Long) { + val timeoutMs = _state.value.lockTimeoutMinutes * 60 * 1000L + if (timeoutMs > 0 && System.currentTimeMillis() - lastBackgroundTimeMs >= timeoutMs) { + lock() + } + } + + fun setBiometricEnabled(enabled: Boolean) { + settingsPrefs.edit().putBoolean("biometric_enabled", enabled).apply() + _state.update { it.copy(biometricEnabled = enabled) } + } + + fun setLockTimeoutMinutes(minutes: Int) { + settingsPrefs.edit().putInt("lock_timeout_minutes", minutes).apply() + _state.update { it.copy(lockTimeoutMinutes = minutes) } + } + + fun getDeviceSecret(): ByteArray = deviceSecretManager.exportSecret() + + fun importDeviceSecret(secret: ByteArray) = deviceSecretManager.importSecret(secret) +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeviceSecretManager.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeviceSecretManager.kt new file mode 100644 index 0000000..9defed8 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/DeviceSecretManager.kt @@ -0,0 +1,41 @@ +package cz.bugsy.passwordzebra.deterministic + +import android.content.Context +import android.util.Base64 +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import java.security.SecureRandom + +class DeviceSecretManager(context: Context) { + + private val prefs by lazy { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + "device_secret_prefs", + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun getOrCreateSecret(): ByteArray { + val existing = prefs.getString(KEY_SECRET, null) + if (existing != null) { + return Base64.decode(existing, Base64.NO_WRAP) + } + val secret = SecureRandom.getSeed(32) + prefs.edit().putString(KEY_SECRET, Base64.encodeToString(secret, Base64.NO_WRAP)).apply() + return secret + } + + fun importSecret(secret: ByteArray) { + prefs.edit().putString(KEY_SECRET, Base64.encodeToString(secret, Base64.NO_WRAP)).apply() + } + + fun exportSecret(): ByteArray = getOrCreateSecret() + + companion object { + private const val KEY_SECRET = "device_secret" + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt new file mode 100644 index 0000000..5e78438 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/deterministic/ServiceHistoryRepository.kt @@ -0,0 +1,45 @@ +package cz.bugsy.passwordzebra.deterministic + +import android.content.Context + +class ServiceHistoryRepository(context: Context) { + + private val prefs = context.getSharedPreferences("service_history", Context.MODE_PRIVATE) + + fun getServices(): List> { + val raw = prefs.getString(KEY_SERVICES, "") ?: "" + if (raw.isBlank()) return emptyList() + return raw.split("|").mapNotNull { entry -> + val colonIdx = entry.lastIndexOf(':') + if (colonIdx < 0) return@mapNotNull null + val name = entry.substring(0, colonIdx) + val counter = entry.substring(colonIdx + 1).toIntOrNull() ?: return@mapNotNull null + name to counter + } + } + + fun upsert(name: String, counter: Int) { + val services = getServices().toMutableList() + val idx = services.indexOfFirst { it.first == name } + if (idx >= 0) { + services[idx] = name to counter + } else { + services.add(0, name to counter) + } + save(services) + } + + fun delete(name: String) { + val services = getServices().filter { it.first != name } + save(services) + } + + private fun save(services: List>) { + val raw = services.joinToString("|") { "${it.first}:${it.second}" } + prefs.edit().putString(KEY_SERVICES, raw).apply() + } + + companion object { + private const val KEY_SERVICES = "services" + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..9f64b48 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/navigation/AppNavigation.kt @@ -0,0 +1,125 @@ +package cz.bugsy.passwordzebra.ui.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel +import cz.bugsy.passwordzebra.ui.screens.DeterministicScreen +import cz.bugsy.passwordzebra.ui.screens.ExportImportScreen +import cz.bugsy.passwordzebra.ui.screens.ServiceHistoryScreen +import cz.bugsy.passwordzebra.ui.screens.SettingsScreen +import cz.bugsy.passwordzebra.ui.screens.WordGeneratorScreen + +private const val ROUTE_WORD_GENERATOR = "word_generator" +private const val ROUTE_DETERMINISTIC = "deterministic" +private const val ROUTE_SERVICE_HISTORY = "service_history" +private const val ROUTE_EXPORT_IMPORT = "export_import" +private const val ROUTE_SETTINGS = "settings" + +private val bottomBarRoutes = setOf(ROUTE_WORD_GENERATOR, ROUTE_DETERMINISTIC) + +@Composable +fun AppNavigation() { + val navController = rememberNavController() + val detViewModel: DeterministicViewModel = viewModel() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val showBottomBar = currentRoute in bottomBarRoutes + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar { + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { + it.route == ROUTE_WORD_GENERATOR + } == true, + onClick = { + navController.navigate(ROUTE_WORD_GENERATOR) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.pw_generate), + contentDescription = null, + ) + }, + label = { Text(stringResource(R.string.nav_word_generator)) }, + ) + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { + it.route == ROUTE_DETERMINISTIC + } == true, + onClick = { + navController.navigate(ROUTE_DETERMINISTIC) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + ) + }, + label = { Text(stringResource(R.string.nav_deterministic)) }, + ) + } + } + }, + ) { _ -> + NavHost(navController = navController, startDestination = ROUTE_WORD_GENERATOR) { + composable(ROUTE_WORD_GENERATOR) { + WordGeneratorScreen() + } + composable(ROUTE_DETERMINISTIC) { + DeterministicScreen( + viewModel = detViewModel, + onNavigateToHistory = { navController.navigate(ROUTE_SERVICE_HISTORY) }, + onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) }, + ) + } + composable(ROUTE_SERVICE_HISTORY) { + ServiceHistoryScreen( + viewModel = detViewModel, + onNavigateBack = { navController.popBackStack() }, + onServiceSelected = { _, _ -> navController.popBackStack() }, + ) + } + composable(ROUTE_SETTINGS) { + SettingsScreen( + viewModel = detViewModel, + onNavigateBack = { navController.popBackStack() }, + onNavigateToExportImport = { navController.navigate(ROUTE_EXPORT_IMPORT) }, + ) + } + composable(ROUTE_EXPORT_IMPORT) { + ExportImportScreen( + viewModel = detViewModel, + onNavigateBack = { navController.popBackStack() }, + ) + } + } + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt new file mode 100644 index 0000000..d4817bd --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/DeterministicScreen.kt @@ -0,0 +1,207 @@ +package cz.bugsy.passwordzebra.ui.screens + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeterministicScreen( + viewModel: DeterministicViewModel, + onNavigateToHistory: () -> Unit, + onNavigateToSettings: () -> Unit, +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + if (state.isLocked) { + LockScreen(viewModel = viewModel) + return + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.det_screen_title)) }, + actions = { + IconButton(onClick = onNavigateToHistory) { + Icon(Icons.Default.History, contentDescription = stringResource(R.string.det_history)) + } + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.det_settings)) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(modifier = Modifier.height(4.dp)) + + // Service name + OutlinedTextField( + value = state.serviceName, + onValueChange = { viewModel.updateServiceName(it) }, + label = { Text(stringResource(R.string.det_service_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // Master password + OutlinedTextField( + value = String(state.masterPassword), + onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) }, + label = { Text(stringResource(R.string.det_master_password)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { viewModel.toggleMasterPasswordVisible() }) { + Icon( + imageVector = if (state.masterPasswordVisible) Icons.Default.VisibilityOff + else Icons.Default.Visibility, + contentDescription = stringResource(R.string.det_toggle_pw_visibility), + ) + } + }, + ) + + // Counter + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.det_counter), + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { viewModel.decrementCounter() }) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = "-", + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = state.counter.toString(), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + IconButton(onClick = { viewModel.incrementCounter() }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "+", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + // Generate button + Button( + onClick = { viewModel.generate() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.det_generate)) + } + + // Generated password box + if (state.generatedPassword.isNotEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ) + .clickable { viewModel.copyAndScheduleClear(context) } + .padding(16.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text( + text = state.generatedPassword, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.titleMedium, + ) + if (state.clipboardTimerSeconds > 0) { + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + Text( + text = " ${state.clipboardTimerSeconds}s", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + } else { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.det_tap_to_copy), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt new file mode 100644 index 0000000..d920e47 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt @@ -0,0 +1,247 @@ +package cz.bugsy.passwordzebra.ui.screens + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportImportScreen( + viewModel: DeterministicViewModel, + onNavigateBack: () -> Unit, +) { + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + var showConfirmDialog by remember { mutableStateOf(false) } + var pendingImportSecret by remember { mutableStateOf(null) } + var confirmPassword by remember { mutableStateOf("") } + var confirmError by remember { mutableStateOf(false) } + + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + val rawValue = result.contents ?: return@rememberLauncherForActivityResult + // Parse passwordzebra://device-secret?v=1&secret= + if (rawValue.startsWith("passwordzebra://device-secret")) { + val secretParam = rawValue.substringAfter("secret=", "") + if (secretParam.isNotEmpty()) { + val decoded = try { + Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } catch (_: Exception) { null } + if (decoded != null && decoded.size == 32) { + pendingImportSecret = decoded + showConfirmDialog = true + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.det_export_import_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.nav_back)) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + TabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text(stringResource(R.string.det_export_tab), modifier = Modifier.padding(vertical = 12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text(stringResource(R.string.det_import_tab), modifier = Modifier.padding(vertical = 12.dp)) + } + } + + when (selectedTab) { + 0 -> ExportTab(viewModel = viewModel) + 1 -> ImportTab(onScan = { + val opts = ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("") + setBeepEnabled(false) + } + scanLauncher.launch(opts) + }) + } + } + } + + if (showConfirmDialog) { + AlertDialog( + onDismissRequest = { + showConfirmDialog = false + pendingImportSecret = null + confirmPassword = "" + confirmError = false + }, + title = { Text(stringResource(R.string.det_import_confirm_title)) }, + text = { + Column { + Text(stringResource(R.string.det_import_confirm_body)) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it; confirmError = false }, + label = { Text(stringResource(R.string.det_master_password)) }, + visualTransformation = PasswordVisualTransformation(), + isError = confirmError, + supportingText = if (confirmError) { + { Text(stringResource(R.string.det_import_wrong_password)) } + } else null, + ) + } + }, + confirmButton = { + TextButton(onClick = { + // We just require any non-empty master password as confirmation + if (confirmPassword.isNotEmpty()) { + pendingImportSecret?.let { viewModel.importDeviceSecret(it) } + showConfirmDialog = false + pendingImportSecret = null + confirmPassword = "" + confirmError = false + } else { + confirmError = true + } + }) { + Text(stringResource(R.string.det_import_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { + showConfirmDialog = false + pendingImportSecret = null + confirmPassword = "" + confirmError = false + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun ExportTab(viewModel: DeterministicViewModel) { + var qrBitmap by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + val secret = viewModel.getDeviceSecret() + val b64 = Base64.encodeToString(secret, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + val uri = "passwordzebra://device-secret?v=1&secret=$b64" + qrBitmap = generateQrBitmap(uri, 512) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.det_export_body), + style = MaterialTheme.typography.bodyMedium, + ) + qrBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.det_export_qr_desc), + modifier = Modifier.size(280.dp), + ) + } + } +} + +@Composable +private fun ImportTab(onScan: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.det_import_body), + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onScan, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.det_scan_qr)) + } + } +} + +private fun generateQrBitmap(content: String, size: Int): Bitmap? { + return try { + val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, size, size) + val width = bitMatrix.width + val height = bitMatrix.height + val pixels = IntArray(width * height) { i -> + val x = i % width + val y = i / width + if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() + } + Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/LockScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/LockScreen.kt new file mode 100644 index 0000000..64b501d --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/LockScreen.kt @@ -0,0 +1,97 @@ +package cz.bugsy.passwordzebra.ui.screens + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel + +@Composable +fun LockScreen(viewModel: DeterministicViewModel) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + fun launchBiometric() { + val activity = context as? FragmentActivity ?: return + val executor = ContextCompat.getMainExecutor(context) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + viewModel.unlock() + } + }, + ) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.det_biometric_prompt_title)) + .setSubtitle(context.getString(R.string.det_biometric_prompt_subtitle)) + .setNegativeButtonText(context.getString(R.string.cancel)) + .build() + prompt.authenticate(promptInfo) + } + + LaunchedEffect(Unit) { + if (state.biometricEnabled) { + launchBiometric() + } + } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.det_locked_title), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.det_locked_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(32.dp)) + if (state.biometricEnabled) { + Button(onClick = { launchBiometric() }) { + Text(stringResource(R.string.det_unlock_biometric)) + } + } + } + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt new file mode 100644 index 0000000..ed4f5b4 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ServiceHistoryScreen.kt @@ -0,0 +1,117 @@ +package cz.bugsy.passwordzebra.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServiceHistoryScreen( + viewModel: DeterministicViewModel, + onNavigateBack: () -> Unit, + onServiceSelected: (name: String, counter: Int) -> Unit, +) { + // Read services on each recomposition so deletes reflect immediately + var services by remember { mutableStateOf(viewModel.historyRepository.getServices()) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.det_history_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.nav_back)) + } + }, + ) + }, + ) { innerPadding -> + if (services.isEmpty()) { + Text( + text = stringResource(R.string.det_history_empty), + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(services, key = { it.first }) { (name, counter) -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.selectService(name, counter) + onServiceSelected(name, counter) + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = "#$counter", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + IconButton(onClick = { + viewModel.historyRepository.delete(name) + services = viewModel.historyRepository.getServices() + }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.det_delete_service), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..ea4047a --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt @@ -0,0 +1,182 @@ +package cz.bugsy.passwordzebra.ui.screens + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: DeterministicViewModel, + onNavigateBack: () -> Unit, + onNavigateToExportImport: () -> Unit, +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + val timeoutOptions = listOf(0, 1, 5, 15) + var timeoutDropdownExpanded by remember { mutableStateOf(false) } + + fun timeoutLabel(minutes: Int): String = when (minutes) { + 0 -> context.getString(R.string.det_timeout_off) + else -> context.getString(R.string.det_timeout_minutes, minutes) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.det_settings_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.nav_back)) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(modifier = Modifier.height(4.dp)) + + // Biometric toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.det_biometric_toggle), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.det_biometric_toggle_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = state.biometricEnabled, + onCheckedChange = { enabled -> + if (enabled) { + val bio = BiometricManager.from(context) + if (bio.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + ) { + viewModel.setBiometricEnabled(true) + } + } else { + viewModel.setBiometricEnabled(false) + } + }, + ) + } + + HorizontalDivider() + + // Lock timeout + Text( + text = stringResource(R.string.det_lock_timeout), + style = MaterialTheme.typography.bodyLarge, + ) + ExposedDropdownMenuBox( + expanded = timeoutDropdownExpanded, + onExpandedChange = { timeoutDropdownExpanded = it }, + ) { + OutlinedTextField( + value = timeoutLabel(state.lockTimeoutMinutes), + onValueChange = {}, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeoutDropdownExpanded) }, + ) + ExposedDropdownMenu( + expanded = timeoutDropdownExpanded, + onDismissRequest = { timeoutDropdownExpanded = false }, + ) { + timeoutOptions.forEach { minutes -> + DropdownMenuItem( + text = { Text(timeoutLabel(minutes)) }, + onClick = { + viewModel.setLockTimeoutMinutes(minutes) + timeoutDropdownExpanded = false + }, + ) + } + } + } + + HorizontalDivider() + + // Argon2 info + Text( + text = stringResource(R.string.det_argon2_info_title), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.det_argon2_info_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + HorizontalDivider() + + // Export / Import link + Button( + onClick = onNavigateToExportImport, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.det_export_import_title)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/WordGeneratorScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/WordGeneratorScreen.kt new file mode 100644 index 0000000..998b4b3 --- /dev/null +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/WordGeneratorScreen.kt @@ -0,0 +1,279 @@ +package cz.bugsy.passwordzebra.ui.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.res.Configuration +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxHeight +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.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cz.bugsy.passwordzebra.PasswordGenerator +import cz.bugsy.passwordzebra.R +import cz.bugsy.passwordzebra.ui.components.NumberPickerView + +@Composable +fun WordGeneratorScreen() { + var password by rememberSaveable { mutableStateOf("") } + var wordCount by rememberSaveable { mutableIntStateOf(4) } + var withoutSpaces by rememberSaveable { mutableStateOf(false) } + var addSpecialChars by rememberSaveable { mutableStateOf(false) } + + val context = LocalContext.current + + LaunchedEffect(Unit) { + if (password.isEmpty()) { + password = generatePassword(wordCount, addSpecialChars) + } + } + + val onGenerate = { password = generatePassword(wordCount, addSpecialChars) } + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + if (isLandscape) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ControlsPanel( + wordCount = wordCount, + withoutSpaces = withoutSpaces, + addSpecialChars = addSpecialChars, + onWordCountChange = { wordCount = it }, + onWithoutSpacesChange = { withoutSpaces = it }, + onAddSpecialCharsChange = { addSpecialChars = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PasswordBox( + password = password, + onClick = { copyToClipboard(context, password, withoutSpaces) }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + Spacer(modifier = Modifier.height(12.dp)) + GenerateButton(onClick = onGenerate) + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + PasswordBox( + password = password, + onClick = { copyToClipboard(context, password, withoutSpaces) }, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + ) + Spacer(modifier = Modifier.weight(2f)) + ControlsPanel( + wordCount = wordCount, + withoutSpaces = withoutSpaces, + addSpecialChars = addSpecialChars, + onWordCountChange = { wordCount = it }, + onWithoutSpacesChange = { withoutSpaces = it }, + onAddSpecialCharsChange = { addSpecialChars = it }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.weight(3f)) + GenerateButton(onClick = onGenerate) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun PasswordBox(password: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ) + .clickable { onClick() } + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = password, + fontSize = 24.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun ControlsPanel( + wordCount: Int, + withoutSpaces: Boolean, + addSpecialChars: Boolean, + onWordCountChange: (Int) -> Unit, + onWithoutSpacesChange: (Boolean) -> Unit, + onAddSpecialCharsChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(R.drawable.pw_straighten), + contentDescription = stringResource(R.string.image_length_desc), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.textview_password_length), + modifier = Modifier.weight(1f), + ) + NumberPickerView( + value = wordCount, + minValue = 1, + maxValue = 10, + onValueChange = onWordCountChange, + modifier = Modifier.widthIn(min = 80.dp), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(R.drawable.pw_letter_spacing), + contentDescription = stringResource(R.string.image_wospaces), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.switch_without_spaces), + modifier = Modifier.weight(1f), + ) + Switch( + checked = withoutSpaces, + onCheckedChange = onWithoutSpacesChange, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(R.drawable.pw_special_character), + contentDescription = stringResource(R.string.image_specialchars), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.switch_special_characters), + modifier = Modifier.weight(1f), + ) + Switch( + checked = addSpecialChars, + onCheckedChange = onAddSpecialCharsChange, + ) + } + } +} + +@Composable +private fun GenerateButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + FilledIconButton( + onClick = onClick, + modifier = modifier.size(60.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + painter = painterResource(R.drawable.pw_generate), + contentDescription = stringResource(R.string.button_generate_description), + ) + } +} + +private fun generatePassword(wordCount: Int, addSpecialChars: Boolean): String { + return buildString { + repeat(wordCount) { + append(PasswordGenerator.generateRandomWord()) + append(" ") + } + }.let { password -> + if (addSpecialChars) { + PasswordGenerator.addSpecialCharacters(password) + } else { + password + } + }.trim() +} + +private fun copyToClipboard(context: Context, text: String, withoutSpaces: Boolean) { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipText = if (withoutSpaces) text.filter { !it.isWhitespace() } else text + val clipData = ClipData.newPlainText("Password", clipText) + clipboardManager.setPrimaryClip(clipData) +} diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index cc52451..f819d6f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -10,4 +10,57 @@ Vybrat délku hesla Kopírovat bez mezer Přidat speciální znaky - \ No newline at end of file + + + Generátor + Trezor + Zpět + Zrušit + + + Deterministický trezor + Služba / Web + Hlavní heslo + Zobrazit/skrýt heslo + Čítač + Možnosti + Generovat + Dotekem zkopírujte + Historie služeb + Nastavení + + + Historie služeb + Žádné uložené služby. + Smazat službu + + + Export / Import tajemství + Export + Import + Naskenujte tento QR kód na jiném zařízení pro přenos device secret. Uchovejte jej v tajnosti – kdokoli s tímto kódem může znovu vygenerovat vaše hesla. + QR kód pro export device secret + Naskenujte QR kód exportovaný z jiného zařízení pro import device secret. + Naskenovat QR kód + Potvrdit import + Tím nahradíte svůj stávající device secret. Zadejte hlavní heslo pro potvrzení. + Importovat + Pro potvrzení importu je vyžadováno hlavní heslo. + + + Nastavení + Biometrické odemčení + Odemkněte trezor otiskem prstu nebo tváří + Automatické zamčení + Vypnuto + %d minut + Parametry Argon2id + Paměť: 64 MB · Iterace: 3 · Paralelismus: 1 · Výstup: 64 bajtů + + + Trezor zamčen + Trezor byl zamčen z důvodu nečinnosti. + Odemknout biometrikou + Odemknout trezor + Ověřte se pro přístup do trezoru + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 688543f..22fc7e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,4 +9,57 @@ Select password length Copy password without spaces Add special characters - \ No newline at end of file + + + Generator + Vault + Back + Cancel + + + Deterministic Vault + Service / Website + Master Password + Toggle password visibility + Counter + Options + Generate + Tap to copy + Service history + Settings + + + Service History + No services saved yet. + Delete service + + + Export / Import Secret + Export + Import + Scan this QR code on another device to transfer your device secret. Keep it private — anyone with this code can regenerate your passwords. + QR code for device secret export + Scan a QR code exported from another device to import its device secret. + Scan QR Code + Confirm Import + This will replace your current device secret. Enter your master password to confirm. + Import + Master password is required to confirm import. + + + Settings + Biometric Unlock + Use fingerprint or face to unlock the vault + Auto-lock timeout + Disabled + %d minutes + Argon2id Parameters + Memory: 64 MB · Iterations: 3 · Parallelism: 1 · Output: 64 bytes + + + Vault Locked + The vault was locked due to inactivity. + Unlock with Biometrics + Unlock Vault + Authenticate to access your vault +