Add syllable-based deterministic password vault

This commit is contained in:
pavelb 2026-03-04 22:24:17 +01:00
parent 48923fa892
commit 72fa3f9f86
16 changed files with 1744 additions and 285 deletions

View File

@ -5,12 +5,12 @@ plugins {
android { android {
namespace = "cz.bugsy.passwordzebra" namespace = "cz.bugsy.passwordzebra"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "cz.bugsy.passwordzebra" applicationId = "cz.bugsy.passwordzebra"
minSdk = 29 minSdk = 29
targetSdk = 34 targetSdk = 35
versionCode = 9 versionCode = 9
versionName = "1.7.0" versionName = "1.7.0"
@ -39,6 +39,11 @@ android {
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.10" kotlinCompilerExtensionVersion = "1.5.10"
} }
packaging {
resources {
excludes += "META-INF/versions/9/OSGI-INF/**"
}
}
} }
dependencies { dependencies {
@ -53,6 +58,26 @@ dependencies {
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") 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-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="MediaStore.createWriteRequest" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application <application
android:name=".PasswordZebra" android:name=".PasswordZebra"
android:allowBackup="true" android:allowBackup="true"

View File

@ -1,298 +1,22 @@
package cz.bugsy.passwordzebra package cz.bugsy.passwordzebra
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager.LayoutParams.FLAG_SECURE
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.border import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
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.ui.components.NumberPickerView
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(FLAG_SECURE)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PasswordZebraTheme { PasswordZebraTheme {
PasswordZebraApp() AppNavigation()
} }
} }
} }
} }
@Composable
private fun PasswordZebraApp() {
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) {
// 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)
}

View File

@ -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()
}
}

View File

@ -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<DeterministicState> = _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)
}

View File

@ -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"
}
}

View File

@ -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<Pair<String, Int>> {
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<Pair<String, Int>>) {
val raw = services.joinToString("|") { "${it.first}:${it.second}" }
prefs.edit().putString(KEY_SERVICES, raw).apply()
}
companion object {
private const val KEY_SERVICES = "services"
}
}

View File

@ -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() },
)
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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<ByteArray?>(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=<base64>
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<Bitmap?>(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
}
}

View File

@ -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))
}
}
}
}
}

View File

@ -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,
)
}
}
}
}
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}

View File

@ -10,4 +10,57 @@
<string name="image_length_desc">Vybrat délku hesla</string> <string name="image_length_desc">Vybrat délku hesla</string>
<string name="image_wospaces">Kopírovat bez mezer</string> <string name="image_wospaces">Kopírovat bez mezer</string>
<string name="image_specialchars">Přidat speciální znaky</string> <string name="image_specialchars">Přidat speciální znaky</string>
</resources>
<!-- Navigace -->
<string name="nav_word_generator">Generátor</string>
<string name="nav_deterministic">Trezor</string>
<string name="nav_back">Zpět</string>
<string name="cancel">Zrušit</string>
<!-- Deterministická obrazovka -->
<string name="det_screen_title">Deterministický trezor</string>
<string name="det_service_name">Služba / Web</string>
<string name="det_master_password">Hlavní heslo</string>
<string name="det_toggle_pw_visibility">Zobrazit/skrýt heslo</string>
<string name="det_counter">Čítač</string>
<string name="det_options">Možnosti</string>
<string name="det_generate">Generovat</string>
<string name="det_tap_to_copy">Dotekem zkopírujte</string>
<string name="det_history">Historie služeb</string>
<string name="det_settings">Nastavení</string>
<!-- Historie služeb -->
<string name="det_history_title">Historie služeb</string>
<string name="det_history_empty">Žádné uložené služby.</string>
<string name="det_delete_service">Smazat službu</string>
<!-- Export / Import -->
<string name="det_export_import_title">Export / Import tajemství</string>
<string name="det_export_tab">Export</string>
<string name="det_import_tab">Import</string>
<string name="det_export_body">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.</string>
<string name="det_export_qr_desc">QR kód pro export device secret</string>
<string name="det_import_body">Naskenujte QR kód exportovaný z jiného zařízení pro import device secret.</string>
<string name="det_scan_qr">Naskenovat QR kód</string>
<string name="det_import_confirm_title">Potvrdit import</string>
<string name="det_import_confirm_body">Tím nahradíte svůj stávající device secret. Zadejte hlavní heslo pro potvrzení.</string>
<string name="det_import_confirm">Importovat</string>
<string name="det_import_wrong_password">Pro potvrzení importu je vyžadováno hlavní heslo.</string>
<!-- Nastavení -->
<string name="det_settings_title">Nastavení</string>
<string name="det_biometric_toggle">Biometrické odemčení</string>
<string name="det_biometric_toggle_desc">Odemkněte trezor otiskem prstu nebo tváří</string>
<string name="det_lock_timeout">Automatické zamčení</string>
<string name="det_timeout_off">Vypnuto</string>
<string name="det_timeout_minutes">%d minut</string>
<string name="det_argon2_info_title">Parametry Argon2id</string>
<string name="det_argon2_info_body">Paměť: 64 MB · Iterace: 3 · Paralelismus: 1 · Výstup: 64 bajtů</string>
<!-- Zamčená obrazovka -->
<string name="det_locked_title">Trezor zamčen</string>
<string name="det_locked_body">Trezor byl zamčen z důvodu nečinnosti.</string>
<string name="det_unlock_biometric">Odemknout biometrikou</string>
<string name="det_biometric_prompt_title">Odemknout trezor</string>
<string name="det_biometric_prompt_subtitle">Ověřte se pro přístup do trezoru</string>
</resources>

View File

@ -9,4 +9,57 @@
<string name="image_length_desc">Select password length</string> <string name="image_length_desc">Select password length</string>
<string name="image_wospaces">Copy password without spaces</string> <string name="image_wospaces">Copy password without spaces</string>
<string name="image_specialchars">Add special characters</string> <string name="image_specialchars">Add special characters</string>
</resources>
<!-- Navigation -->
<string name="nav_word_generator">Generator</string>
<string name="nav_deterministic">Vault</string>
<string name="nav_back">Back</string>
<string name="cancel">Cancel</string>
<!-- Deterministic screen -->
<string name="det_screen_title">Deterministic Vault</string>
<string name="det_service_name">Service / Website</string>
<string name="det_master_password">Master Password</string>
<string name="det_toggle_pw_visibility">Toggle password visibility</string>
<string name="det_counter">Counter</string>
<string name="det_options">Options</string>
<string name="det_generate">Generate</string>
<string name="det_tap_to_copy">Tap to copy</string>
<string name="det_history">Service history</string>
<string name="det_settings">Settings</string>
<!-- Service history -->
<string name="det_history_title">Service History</string>
<string name="det_history_empty">No services saved yet.</string>
<string name="det_delete_service">Delete service</string>
<!-- Export / Import -->
<string name="det_export_import_title">Export / Import Secret</string>
<string name="det_export_tab">Export</string>
<string name="det_import_tab">Import</string>
<string name="det_export_body">Scan this QR code on another device to transfer your device secret. Keep it private — anyone with this code can regenerate your passwords.</string>
<string name="det_export_qr_desc">QR code for device secret export</string>
<string name="det_import_body">Scan a QR code exported from another device to import its device secret.</string>
<string name="det_scan_qr">Scan QR Code</string>
<string name="det_import_confirm_title">Confirm Import</string>
<string name="det_import_confirm_body">This will replace your current device secret. Enter your master password to confirm.</string>
<string name="det_import_confirm">Import</string>
<string name="det_import_wrong_password">Master password is required to confirm import.</string>
<!-- Settings -->
<string name="det_settings_title">Settings</string>
<string name="det_biometric_toggle">Biometric Unlock</string>
<string name="det_biometric_toggle_desc">Use fingerprint or face to unlock the vault</string>
<string name="det_lock_timeout">Auto-lock timeout</string>
<string name="det_timeout_off">Disabled</string>
<string name="det_timeout_minutes">%d minutes</string>
<string name="det_argon2_info_title">Argon2id Parameters</string>
<string name="det_argon2_info_body">Memory: 64 MB · Iterations: 3 · Parallelism: 1 · Output: 64 bytes</string>
<!-- Lock screen -->
<string name="det_locked_title">Vault Locked</string>
<string name="det_locked_body">The vault was locked due to inactivity.</string>
<string name="det_unlock_biometric">Unlock with Biometrics</string>
<string name="det_biometric_prompt_title">Unlock Vault</string>
<string name="det_biometric_prompt_subtitle">Authenticate to access your vault</string>
</resources>