Add syllable-based deterministic password vault
This commit is contained in:
parent
39e1640a90
commit
d450ac3b8c
@ -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")
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:name=".PasswordZebra"
|
||||
android:allowBackup="true"
|
||||
|
||||
@ -1,298 +1,22 @@
|
||||
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.view.WindowManager.LayoutParams.FLAG_SECURE
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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.ui.components.NumberPickerView
|
||||
import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
|
||||
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(FLAG_SECURE)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -10,4 +10,57 @@
|
||||
<string name="image_length_desc">Vybrat délku hesla</string>
|
||||
<string name="image_wospaces">Kopírovat bez mezer</string>
|
||||
<string name="image_specialchars">Přidat speciální znaky</string>
|
||||
|
||||
<!-- 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>
|
||||
@ -9,4 +9,57 @@
|
||||
<string name="image_length_desc">Select password length</string>
|
||||
<string name="image_wospaces">Copy password without spaces</string>
|
||||
<string name="image_specialchars">Add special characters</string>
|
||||
|
||||
<!-- 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>
|
||||
Loading…
x
Reference in New Issue
Block a user