Separate service autocomplete history from password counters

This commit is contained in:
Pavel Baksy 2026-03-04 23:31:58 +01:00
parent e29d0e633f
commit 063e12c06b
9 changed files with 206 additions and 97 deletions

View File

@ -18,12 +18,12 @@ 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,
val showRotateWarning: Boolean = false,
) {
// CharArray doesn't support structural equality; override manually
override fun equals(other: Any?): Boolean {
@ -32,24 +32,24 @@ data class DeterministicState(
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
lockTimeoutMinutes == other.lockTimeoutMinutes &&
showRotateWarning == other.showRotateWarning
}
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
result = 31 * result + showRotateWarning.hashCode()
return result
}
}
@ -58,6 +58,7 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
private val deviceSecretManager = DeviceSecretManager(app)
val historyRepository = ServiceHistoryRepository(app)
val counterRepository = ServiceCounterRepository(app)
private val _state = MutableStateFlow(DeterministicState())
val state: StateFlow<DeterministicState> = _state.asStateFlow()
@ -82,28 +83,43 @@ class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
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 selectService(name: String) = _state.update { it.copy(serviceName = name) }
fun generate() {
val s = _state.value
if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return
val counter = counterRepository.getCounter(s.serviceName)
val deviceSecret = deviceSecretManager.getOrCreateSecret()
val password = DeterministicPasswordGenerator.generate(
masterPassword = s.masterPassword,
deviceSecret = deviceSecret,
serviceName = s.serviceName,
counter = s.counter,
counter = counter,
options = SyllableOptions(),
)
historyRepository.upsert(s.serviceName, s.counter)
historyRepository.upsert(s.serviceName)
counterRepository.upsert(s.serviceName, counter)
_state.update { it.copy(generatedPassword = password) }
}
fun rotatePassword() {
val s = _state.value
if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return
val newCounter = counterRepository.getCounter(s.serviceName) + 1
counterRepository.upsert(s.serviceName, newCounter)
val deviceSecret = deviceSecretManager.getOrCreateSecret()
val password = DeterministicPasswordGenerator.generate(
masterPassword = s.masterPassword,
deviceSecret = deviceSecret,
serviceName = s.serviceName,
counter = newCounter,
options = SyllableOptions(),
)
_state.update { it.copy(generatedPassword = password, showRotateWarning = true) }
}
fun dismissRotateWarning() = _state.update { it.copy(showRotateWarning = false) }
fun copyAndScheduleClear(context: Context) {
val password = _state.value.generatedPassword
if (password.isEmpty()) return

View File

@ -0,0 +1,39 @@
package cz.bugsy.passwordzebra.deterministic
import android.content.Context
class ServiceCounterRepository(context: Context) {
private val prefs = context.getSharedPreferences("service_counters", Context.MODE_PRIVATE)
fun getCounter(name: String): Int =
getAll().firstOrNull { it.first == name }?.second ?: 1
fun upsert(name: String, counter: Int) {
val entries = getAll().toMutableList()
val idx = entries.indexOfFirst { it.first == name }
if (idx >= 0) entries[idx] = name to counter else entries.add(name to counter)
save(entries)
}
fun getAll(): List<Pair<String, Int>> {
val raw = prefs.getString(KEY_COUNTERS, "") ?: ""
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
}
}
private fun save(entries: List<Pair<String, Int>>) {
val raw = entries.joinToString("|") { "${it.first}:${it.second}" }
prefs.edit().putString(KEY_COUNTERS, raw).apply()
}
companion object {
private const val KEY_COUNTERS = "counters"
}
}

View File

@ -6,37 +6,33 @@ class ServiceHistoryRepository(context: Context) {
private val prefs = context.getSharedPreferences("service_history", Context.MODE_PRIVATE)
fun getServices(): List<Pair<String, Int>> {
fun getNames(): List<String> {
val raw = prefs.getString(KEY_SERVICES, "") ?: ""
if (raw.isBlank()) return emptyList()
return raw.split("|").mapNotNull { entry ->
return raw.split("|").map { entry ->
// Migration: strip counter from old "name:counter" format
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
if (colonIdx >= 0 && entry.substring(colonIdx + 1).toIntOrNull() != null) {
entry.substring(0, colonIdx)
} else {
entry
}
}.filter { it.isNotBlank() }
}
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 upsert(name: String) {
val names = getNames().toMutableList()
names.remove(name)
names.add(0, name)
save(names)
}
fun delete(name: String) {
val services = getServices().filter { it.first != name }
save(services)
save(getNames().filter { it != name })
}
private fun save(services: List<Pair<String, Int>>) {
val raw = services.joinToString("|") { "${it.first}:${it.second}" }
prefs.edit().putString(KEY_SERVICES, raw).apply()
private fun save(names: List<String>) {
prefs.edit().putString(KEY_SERVICES, names.joinToString("|")).apply()
}
companion object {

View File

@ -98,13 +98,14 @@ fun AppNavigation() {
viewModel = detViewModel,
onNavigateToHistory = { navController.navigate(ROUTE_SERVICE_HISTORY) },
onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) },
onNavigateToExport = { navController.navigate(ROUTE_EXPORT_IMPORT) },
)
}
composable(ROUTE_SERVICE_HISTORY) {
ServiceHistoryScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() },
onServiceSelected = { _, _ -> navController.popBackStack() },
onServiceSelected = { _ -> navController.popBackStack() },
)
}
composable(ROUTE_SETTINGS) {

View File

@ -1,5 +1,6 @@
package cz.bugsy.passwordzebra.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -11,36 +12,40 @@ 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.width
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.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@ -54,6 +59,7 @@ fun DeterministicScreen(
viewModel: DeterministicViewModel,
onNavigateToHistory: () -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToExport: () -> Unit,
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
@ -62,6 +68,50 @@ fun DeterministicScreen(
return
}
if (state.showRotateWarning) {
AlertDialog(
onDismissRequest = { viewModel.dismissRotateWarning() },
icon = {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = {
Text(
text = stringResource(R.string.det_rotate_warning_title),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
)
},
text = {
Text(stringResource(R.string.det_rotate_warning_body))
},
confirmButton = {
Button(
onClick = {
viewModel.dismissRotateWarning()
onNavigateToExport()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.det_rotate_go_to_export))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissRotateWarning() }) {
Text(
text = stringResource(R.string.det_rotate_later),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
@ -117,36 +167,6 @@ fun DeterministicScreen(
},
)
// 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() },
@ -198,10 +218,23 @@ fun DeterministicScreen(
}
}
}
// Rotate password button — shown only after a password has been generated
OutlinedButton(
onClick = { viewModel.rotatePassword() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) {
Icon(Icons.Default.Warning, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.det_rotate_password))
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@ -3,7 +3,9 @@ package cz.bugsy.passwordzebra.ui.screens
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import org.json.JSONArray
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@ -68,17 +70,29 @@ fun ExportImportScreen(
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=", "")
val uri = Uri.parse(rawValue)
val secretParam = uri.getQueryParameter("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
// Parse v2 services if present
val servicesParam = uri.getQueryParameter("services")
if (servicesParam != null) {
try {
val json = String(Base64.decode(servicesParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
val arr = JSONArray(json)
for (i in 0 until arr.length()) {
val entry = arr.getJSONArray(i)
val name = entry.getString(0)
val counter = entry.getInt(1)
viewModel.counterRepository.upsert(name, counter)
}
} catch (_: Exception) { /* ignore malformed services */ }
}
showConfirmDialog = true
}
}
}
@ -185,7 +199,16 @@ private fun ExportTab(viewModel: DeterministicViewModel) {
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"
val rotatedServices = viewModel.counterRepository.getAll().filter { it.second >= 2 }
val uri = if (rotatedServices.isEmpty()) {
"passwordzebra://backup?v=2&secret=$b64"
} else {
val json = JSONArray().apply {
rotatedServices.forEach { (name, counter) -> put(JSONArray().put(name).put(counter)) }
}.toString()
val servicesB64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
"passwordzebra://backup?v=2&secret=$b64&services=$servicesB64"
}
qrBitmap = generateQrBitmap(uri, 512)
}

View File

@ -20,7 +20,6 @@ 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
@ -37,10 +36,10 @@ import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
fun ServiceHistoryScreen(
viewModel: DeterministicViewModel,
onNavigateBack: () -> Unit,
onServiceSelected: (name: String, counter: Int) -> Unit,
onServiceSelected: (name: String) -> Unit,
) {
// Read services on each recomposition so deletes reflect immediately
var services by remember { mutableStateOf(viewModel.historyRepository.getServices()) }
// Read names on each recomposition so deletes reflect immediately
var services by remember { mutableStateOf(viewModel.historyRepository.getNames()) }
Scaffold(
topBar = {
@ -72,13 +71,13 @@ fun ServiceHistoryScreen(
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(services, key = { it.first }) { (name, counter) ->
items(services, key = { it }) { name ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.selectService(name, counter)
onServiceSelected(name, counter)
viewModel.selectService(name)
onServiceSelected(name)
},
) {
Row(
@ -92,15 +91,9 @@ fun ServiceHistoryScreen(
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()
services = viewModel.historyRepository.getNames()
}) {
Icon(
Icons.Default.Delete,

View File

@ -22,12 +22,16 @@
<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>
<string name="det_rotate_password">Heslo kompromitováno — rotovat</string>
<string name="det_rotate_warning_title">⚠ Aktualizujte QR zálohu — ihned!</string>
<string name="det_rotate_warning_body">Pro tuto službu bylo vygenerováno nové heslo.\n\nVáš záložní QR kód tuto rotaci nezachycuje. Pokud ztratíte zařízení bez aktualizace QR kódu, TOTO HESLO BUDE TRVALE ZTRACENO — žádným jiným způsobem ho nelze obnovit.\n\nOstatní hesla nejsou dotčena.</string>
<string name="det_rotate_go_to_export">Aktualizovat QR zálohu nyní</string>
<string name="det_rotate_later">Udělám to později (riskantní)</string>
<!-- Historie služeb -->
<string name="det_history_title">Historie služeb</string>
@ -38,7 +42,7 @@
<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_body">Naskenujte tento QR kód na jiném zařízení pro obnovu trezoru. Obsahuje device secret a čítače rotovaných služeb. Uchovejte jej v tajnosti kdokoli s tímto kódem a vaším hlavním heslem může znovu vygenerovat všechna 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>

View File

@ -21,12 +21,16 @@
<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>
<string name="det_rotate_password">Password Compromised — Rotate</string>
<string name="det_rotate_warning_title">⚠ Update Your QR Backup — Now!</string>
<string name="det_rotate_warning_body">A new password has been generated for this service.\n\nYour QR backup no longer reflects this rotation. If you lose this device without updating the QR code, THIS PASSWORD WILL BE PERMANENTLY LOST — it cannot be recovered any other way.\n\nYour other passwords are not affected.</string>
<string name="det_rotate_go_to_export">Update QR Backup Now</string>
<string name="det_rotate_later">I\'ll do it later (risky)</string>
<!-- Service history -->
<string name="det_history_title">Service History</string>
@ -37,7 +41,7 @@
<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_body">Scan this QR code on another device to restore your vault. It contains your device secret and any rotated service counters. Keep it private — anyone with this code and your master password can regenerate all 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>