Add syllable-based deterministic password vault
This commit is contained in:
parent
39e1640a90
commit
d450ac3b8c
@ -5,12 +5,12 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "cz.bugsy.passwordzebra"
|
namespace = "cz.bugsy.passwordzebra"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "cz.bugsy.passwordzebra"
|
applicationId = "cz.bugsy.passwordzebra"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 9
|
versionCode = 9
|
||||||
versionName = "1.7.0"
|
versionName = "1.7.0"
|
||||||
|
|
||||||
@ -39,6 +39,11 @@ android {
|
|||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.10"
|
kotlinCompilerExtensionVersion = "1.5.10"
|
||||||
}
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "META-INF/versions/9/OSGI-INF/**"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@ -53,6 +58,26 @@ dependencies {
|
|||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
|
||||||
|
// Navigation + ViewModel
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
|
||||||
|
// Cryptography – Argon2id via Bouncy Castle
|
||||||
|
implementation("org.bouncycastle:bcprov-jdk15on:1.70")
|
||||||
|
|
||||||
|
// EncryptedSharedPreferences (Android Keystore)
|
||||||
|
implementation("androidx.security:security-crypto:1.0.0")
|
||||||
|
|
||||||
|
// QR codes – generation + scanning
|
||||||
|
implementation("com.google.zxing:core:3.5.2")
|
||||||
|
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||||
|
|
||||||
|
// Biometrics
|
||||||
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
|
// Material Icons Extended
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="MediaStore.createWriteRequest" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<application
|
<application
|
||||||
android:name=".PasswordZebra"
|
android:name=".PasswordZebra"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@ -1,298 +1,22 @@
|
|||||||
package cz.bugsy.passwordzebra
|
package cz.bugsy.passwordzebra
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager.LayoutParams.FLAG_SECURE
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.border
|
import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import cz.bugsy.passwordzebra.ui.components.NumberPickerView
|
|
||||||
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
|
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
window.addFlags(FLAG_SECURE)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
PasswordZebraTheme {
|
PasswordZebraTheme {
|
||||||
PasswordZebraApp()
|
AppNavigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PasswordZebraApp() {
|
|
||||||
var password by rememberSaveable { mutableStateOf("") }
|
|
||||||
var wordCount by rememberSaveable { mutableIntStateOf(4) }
|
|
||||||
var withoutSpaces by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var addSpecialChars by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (password.isEmpty()) {
|
|
||||||
password = generatePassword(wordCount, addSpecialChars)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val onGenerate = { password = generatePassword(wordCount, addSpecialChars) }
|
|
||||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
|
||||||
|
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
|
||||||
if (isLandscape) {
|
|
||||||
// Landscape: controls left (vertically centered), password + generate button right
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// Left: controls only, vertically centered
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
ControlsPanel(
|
|
||||||
wordCount = wordCount,
|
|
||||||
withoutSpaces = withoutSpaces,
|
|
||||||
addSpecialChars = addSpecialChars,
|
|
||||||
onWordCountChange = { wordCount = it },
|
|
||||||
onWithoutSpacesChange = { withoutSpaces = it },
|
|
||||||
onAddSpecialCharsChange = { addSpecialChars = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Right: password box fills remaining height, generate button pinned to bottom
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
PasswordBox(
|
|
||||||
password = password,
|
|
||||||
onClick = { copyToClipboard(context, password, withoutSpaces) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
GenerateButton(onClick = onGenerate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Portrait: password box top, controls middle, generate button pinned to bottom
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
PasswordBox(
|
|
||||||
password = password,
|
|
||||||
onClick = { copyToClipboard(context, password, withoutSpaces) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(150.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(2f))
|
|
||||||
ControlsPanel(
|
|
||||||
wordCount = wordCount,
|
|
||||||
withoutSpaces = withoutSpaces,
|
|
||||||
addSpecialChars = addSpecialChars,
|
|
||||||
onWordCountChange = { wordCount = it },
|
|
||||||
onWithoutSpacesChange = { withoutSpaces = it },
|
|
||||||
onAddSpecialCharsChange = { addSpecialChars = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(3f))
|
|
||||||
GenerateButton(onClick = onGenerate)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PasswordBox(password: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.border(
|
|
||||||
width = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
)
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = password,
|
|
||||||
fontSize = 24.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ControlsPanel(
|
|
||||||
wordCount: Int,
|
|
||||||
withoutSpaces: Boolean,
|
|
||||||
addSpecialChars: Boolean,
|
|
||||||
onWordCountChange: (Int) -> Unit,
|
|
||||||
onWithoutSpacesChange: (Boolean) -> Unit,
|
|
||||||
onAddSpecialCharsChange: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.pw_straighten),
|
|
||||||
contentDescription = stringResource(R.string.image_length_desc),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.textview_password_length),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
NumberPickerView(
|
|
||||||
value = wordCount,
|
|
||||||
minValue = 1,
|
|
||||||
maxValue = 10,
|
|
||||||
onValueChange = onWordCountChange,
|
|
||||||
modifier = Modifier.widthIn(min = 80.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.pw_letter_spacing),
|
|
||||||
contentDescription = stringResource(R.string.image_wospaces),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.switch_without_spaces),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = withoutSpaces,
|
|
||||||
onCheckedChange = onWithoutSpacesChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.pw_special_character),
|
|
||||||
contentDescription = stringResource(R.string.image_specialchars),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.switch_special_characters),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = addSpecialChars,
|
|
||||||
onCheckedChange = onAddSpecialCharsChange,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun GenerateButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|
||||||
FilledIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier.size(60.dp),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.pw_generate),
|
|
||||||
contentDescription = stringResource(R.string.button_generate_description),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generatePassword(wordCount: Int, addSpecialChars: Boolean): String {
|
|
||||||
return buildString {
|
|
||||||
repeat(wordCount) {
|
|
||||||
append(PasswordGenerator.generateRandomWord())
|
|
||||||
append(" ")
|
|
||||||
}
|
|
||||||
}.let { password ->
|
|
||||||
if (addSpecialChars) {
|
|
||||||
PasswordGenerator.addSpecialCharacters(password)
|
|
||||||
} else {
|
|
||||||
password
|
|
||||||
}
|
|
||||||
}.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyToClipboard(context: Context, text: String, withoutSpaces: Boolean) {
|
|
||||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
val clipText = if (withoutSpaces) text.filter { !it.isWhitespace() } else text
|
|
||||||
val clipData = ClipData.newPlainText("Password", clipText)
|
|
||||||
clipboardManager.setPrimaryClip(clipData)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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_length_desc">Vybrat délku hesla</string>
|
||||||
<string name="image_wospaces">Kopírovat bez mezer</string>
|
<string name="image_wospaces">Kopírovat bez mezer</string>
|
||||||
<string name="image_specialchars">Přidat speciální znaky</string>
|
<string name="image_specialchars">Přidat speciální znaky</string>
|
||||||
|
|
||||||
|
<!-- 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>
|
</resources>
|
||||||
@ -9,4 +9,57 @@
|
|||||||
<string name="image_length_desc">Select password length</string>
|
<string name="image_length_desc">Select password length</string>
|
||||||
<string name="image_wospaces">Copy password without spaces</string>
|
<string name="image_wospaces">Copy password without spaces</string>
|
||||||
<string name="image_specialchars">Add special characters</string>
|
<string name="image_specialchars">Add special characters</string>
|
||||||
|
|
||||||
|
<!-- 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>
|
</resources>
|
||||||
Loading…
x
Reference in New Issue
Block a user