Compare commits

..

No commits in common. "master" and "1.5.0" have entirely different histories.

41 changed files with 308 additions and 2637 deletions

13
.gitignore vendored
View File

@ -1,16 +1,15 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/ /.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
.claude
CLAUDE.md
keystore.properties
*.jks
*.keystore

View File

@ -15,7 +15,7 @@
</deviceKey> </deviceKey>
</Target> </Target>
</targetSelectedWithDropDown> </targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2024-02-19T08:27:46.302081639Z" /> <timeTargetWasSelectedWithDropDown value="2024-02-16T23:14:28.483192164Z" />
</State> </State>
</entry> </entry>
</value> </value>

20
LICENSE
View File

@ -1,20 +0,0 @@
Password Zebra
Copyright (C) 2024 Pavel Bubenicek
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
[Append the full text of the GNU General Public License v3 below.
Download it from: https://www.gnu.org/licenses/gpl-3.0.txt]

View File

@ -1,68 +0,0 @@
# Password Zebra
An open-source Android password manager with two core features: a syllable-based random password generator and a deterministic password vault. No passwords are stored — they are derived on demand.
**Minimum Android version:** 10 (API 29)
<!-- Screenshots -->
## Features
### Random Password Generator
Generates memorable, pronounceable passwords from a syllable corpus. Options:
- Word count (110)
- Remove spaces
- Add special characters (uppercase letter, digit, special symbol inserted at random positions)
### Deterministic Password Vault
Derives passwords reproducibly from three inputs:
- **Master password** — known only to you, never stored
- **Device secret** — random key generated once and stored in Android Keystore via EncryptedSharedPreferences
- **Service name + counter** — identifies the account and allows rotation
The derivation uses **Argon2id** (memory: 64 MB, iterations: 3) so the same inputs always produce the same password, on any device that has the same device secret.
**Service history** is saved locally so you can quickly regenerate passwords for known services.
### Export / Import
Transfer your device secret and service history to another device using an encrypted QR code, secured with Android device credentials (PIN/pattern/password).
## Security
- Screen content is protected with `FLAG_SECURE` (no screenshots, no recent apps preview)
- Master password is held as `CharArray` and wiped from memory immediately after derivation
- Device secret lives exclusively in Android Keystore-backed EncryptedSharedPreferences
- No network permissions; no data leaves the device
## Build
```bash
# Debug APK
./gradlew assembleDebug
# Install on connected device
./gradlew installDebug
# Unit tests
./gradlew test
# Lint
./gradlew lint
```
Release signing requires a `keystore.properties` file at the project root:
```
storeFile=<path to .jks>
storePassword=<password>
keyAlias=<alias>
keyPassword=<password>
```
## Tech Stack
- Kotlin + Jetpack Compose + Navigation Compose
- Material 3 with dynamic color (Android 12+)
- Argon2id via Bouncy Castle (`bcprov-jdk15on`)
- EncryptedSharedPreferences (`security-crypto`)
- QR export/import via ZXing
## License
GNU General Public License v3.0 — see [LICENSE](LICENSE).

View File

@ -1,42 +1,25 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
val keystoreProps = Properties().also { props ->
val f = rootProject.file("keystore.properties")
if (f.exists()) props.load(f.inputStream())
}
android { android {
namespace = "cz.bugsy.passwordzebra" namespace = "cz.bugsy.passwordzebra"
compileSdk = 35 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "cz.bugsy.passwordzebra" applicationId = "cz.bugsy.passwordzebra"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 34
versionCode = 10 versionCode = 6
versionName = "1.8.0" versionName = "1.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
create("release") {
storeFile = file(keystoreProps["storeFile"] as String)
storePassword = keystoreProps["storePassword"] as String
keyAlias = keystoreProps["keyAlias"] as String
keyPassword = keystoreProps["keyPassword"] as String
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@ -50,58 +33,15 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "1.8"
} }
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
packaging {
resources {
excludes += "META-INF/versions/9/OSGI-INF/**"
}
}
} }
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
val composeBom = platform("androidx.compose:compose-bom:2024.02.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
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")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
} }

View File

@ -1,8 +1,7 @@
<?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="android.permission.CAMERA" /> <uses-permission android:name="MediaStore.createWriteRequest" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application <application
android:name=".PasswordZebra" android:name=".PasswordZebra"
android:allowBackup="true" android:allowBackup="true"

View File

@ -1,22 +1,147 @@
package cz.bugsy.passwordzebra package cz.bugsy.passwordzebra
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager.LayoutParams.FLAG_SECURE import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.setContent import kotlin.random.Random
import androidx.activity.enableEdgeToEdge
import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var generateButton: Button
private lateinit var passwordTextView: TextView
private lateinit var iconImageView: ImageView
private lateinit var switchWithoutSpaces: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var switchSpecialChars: com.google.android.material.switchmaterial.SwitchMaterial
private lateinit var passwordLengthPicker: android.widget.NumberPicker
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(FLAG_SECURE) setContentView(R.layout.activity_main)
enableEdgeToEdge()
setContent { generateButton = findViewById(R.id.generateButton)
PasswordZebraTheme { passwordTextView = findViewById(R.id.passwordTextView)
AppNavigation() //iconImageView = findViewById(R.id.iconImageView)
generateButton = findViewById(R.id.generateButton)
switchWithoutSpaces = findViewById(R.id.switchWithoutSpaces)
switchSpecialChars = findViewById(R.id.switchSpecialChars)
passwordLengthPicker = findViewById(R.id.passwordLengthPicker)
// Set the range of selectable values for password length (1 to 10)
val defaultPasswordLength = 4
passwordLengthPicker.minValue = 1
passwordLengthPicker.maxValue = 10
passwordLengthPicker.value = defaultPasswordLength
generateButton.setOnClickListener {
generatePassword()
}
// Initially generate a password
generatePassword()
// Set click listener to copy password to clipboard
passwordTextView.setOnClickListener {
copyToClipboard(passwordTextView.text.toString(), getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
}
}
private fun generatePassword() {
val passwordLength = passwordLengthPicker.value
val password = generateRandomWords(passwordLength)
passwordTextView.text = password
}
private fun generateRandomWords(numWords: Int): String {
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"
// Add more syllables as needed
)
val random = Random.Default
return buildString {
repeat(numWords) {
append(generateRandomWord(syllables, random))
append(" ") // Add space between words
}
}.let { password ->
// Check if the switch for adding special characters is checked
if (switchSpecialChars.isChecked) {
addSpecialCharacters(password)
} else {
password
}
}.trim() // Remove trailing space
}
private fun generateRandomWord(syllables: Array<String>, random: Random): String {
return buildString {
repeat(random.nextInt(2, 4)) {
append(syllables[random.nextInt(syllables.size)])
} }
} }
} }
private fun copyToClipboard(text: String, clipboardManager: ClipboardManager) {
val clipData = if (switchWithoutSpaces.isChecked)
{ val textNoSpaces = text.filter { !it.isWhitespace() }
ClipData.newPlainText("Password", textNoSpaces)
} else {
ClipData.newPlainText("Password", text)
}
clipboardManager.setPrimaryClip(clipData)
}
private fun addSpecialCharacters(password: String): String {
// Insert special character, uppercase character, and digit at random positions within the password
val specialChars = listOf('$', ',', '.', '#', '@', '!', '%', '&')
val random = Random.Default
val index = random.nextInt(0, password.length + 1)
val specialChar = specialChars[random.nextInt(specialChars.size)]
val uppercaseChar = ('A'..'Z').random() // Generate random uppercase character
val digit = random.nextInt(0, 10) // Generate random digit (number)
val newPassword = StringBuilder(password)
newPassword.insert(index, specialChar)
newPassword.insert(random.nextInt(0, newPassword.length + 1), uppercaseChar)
newPassword.insert(random.nextInt(0, newPassword.length + 1), digit.toString())
//return password.substring(0, index) + specialChar + password.substring(index)
return newPassword.toString()
}
} }

View File

@ -1,64 +0,0 @@
package cz.bugsy.passwordzebra
import kotlin.random.Random
internal object PasswordGenerator {
private val defaultRandom = Random.Default
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"
// Add more syllables as needed
)
fun generateRandomWord(random: Random = defaultRandom): String {
return buildString {
repeat(random.nextInt(2, 4)) {
append(syllables[random.nextInt(syllables.size)])
}
}
}
fun addSpecialCharacters(password: String, random: Random = defaultRandom): String {
// Insert special character, uppercase character, and digit at random positions within the password
val specialChars = listOf('$', ',', '.', '#', '@', '!', '%', '&')
val index = random.nextInt(0, password.length + 1)
val specialChar = specialChars[random.nextInt(specialChars.size)]
val uppercaseChar = ('A'..'Z').random(random)
val digit = random.nextInt(0, 10)
val newPassword = StringBuilder(password)
newPassword.insert(index, specialChar)
newPassword.insert(random.nextInt(0, newPassword.length + 1), uppercaseChar)
newPassword.insert(random.nextInt(0, newPassword.length + 1), digit.toString())
return newPassword.toString()
}
}

View File

@ -5,7 +5,8 @@ import com.google.android.material.color.DynamicColors
class PasswordZebra : Application() { class PasswordZebra : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() // Apply dynamic color
DynamicColors.applyToActivitiesIfAvailable(this) //DynamicColors.applyToActivitiesIfAvailable(this)
DynamicColors.applyToActivitiesIfAvailable(this, R.style.AppTheme_Overlay)
} }
} }

View File

@ -1,113 +0,0 @@
package cz.bugsy.passwordzebra.deterministic
import android.util.Base64
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
data class SyllableOptions(
val wordCount: Int = 5,
val addSpecialChars: Boolean = true,
)
object DeterministicPasswordGenerator {
private const val ARGON2_MEMORY = 65536
private const val ARGON2_ITERATIONS = 3
private const val ARGON2_PARALLELISM = 1
private const val ARGON2_OUTPUT_LEN = 64
private val SPECIAL_CHARS = listOf('$', ',', '.', '#', '@', '!', '%', '&')
// Same syllable corpus as PasswordGenerator (duplicates preserved for natural weighting)
private val syllables = arrayOf(
"ing","er","a","ly","ed","i","es","re","tion","in","e","con","y","ter","ex","al","de","com",
"o","di","en","an","ty","ry","u","ti","ri","be","per","to","pro","ac","ad","ar","ers","ment",
"or","tions","ble","der","ma","na","si","un","at","dis","ca","cal","man","ap","po","sion","vi",
"el","est","la","lar","pa","ture","for","is","mer","pe","ra","so","ta","as","col","fi","ful",
"ger","low","ni","par","son","tle","day","ny","pen","pre","tive","car","ci","mo","on","ous",
"pi","se","ten","tor","ver","ber","can","dy","et","it","mu","no","ple","cu","fac","fer","gen",
"ic","land","light","ob","of","pos","tain","den","ings","mag","ments","set","some","sub","sur",
"ters","tu","af","au","cy","fa","im","li","lo","men","min","mon","op","out","rec","ro","sen",
"side","tal","tic","ties","ward","age","ba","but","cit","cle","co","cov","da","dif","ence",
"ern","eve","hap","ies","ket","lec","main","mar","mis","my","nal","ness","ning","n't","nu","oc",
"pres","sup","te","ted","tem","tin","tri","tro","up","va","ven","vis","am","bor","by","cat",
"cent","ev","gan","gle","head","high","il","lu","me","nore","part","por","read","rep","su",
"tend","ther","ton","try","um","uer","way","ate","bet","bles","bod","cap","cial","cir","cor",
"coun","cus","dan","dle","ef","end","ent","ered","fin","form","go","har","ish","lands","let",
"long","mat","meas","mem","mul","ner","play","ples","ply","port","press","sat","sec","ser",
"south","sun","the","ting","tra","tures","val","var","vid","wil","win","won","work","act","ag",
"air","als","bat","bi","cate","cen","char","come","cul","ders","east","fect","fish","fix","gi",
"grand","great","heav","ho","hunt","ion","its","jo","lat","lead","lect","lent","less","lin",
"mal","mi","mil","moth","near","nel","net","new","one","point","prac","ral","rect","ried",
"round","row","sa","sand","self","sent","ship","sim","sions","sis","sons","stand","sug","tel",
"tom","tors","tract","tray","us","vel","west","where","writing","er","i","y","ter","al","ed",
"es","e","tion","re","o","oth","ry","de","ver","ex","en","di","bout","com","ple","u","con",
"per","un","der","tle","ber","ty","num","peo","ble","af","ers","mer","wa","ment","pro","ar",
"ma","ri","sen","ture","fer","dif","pa","tions","ther","fore","est","fa","la","ei","not","si",
"ent","ven","ev","ac","ca","fol","ful","na","tain","ning","col","par","dis","ern","ny","cit",
"po","cal","mu","moth","pic","im","coun","mon","pe","lar","por","fi","bers","sec","ap","stud",
"ad","tween","gan","bod","tence","ward","hap","nev","ure","mem","ters","cov","ger","nit"
)
fun generate(
masterPassword: CharArray,
deviceSecret: ByteArray,
serviceName: String,
counter: Int,
options: SyllableOptions,
): String {
// Build effective master = masterPassword + Base64(deviceSecret)
val deviceSecretB64 = Base64.encodeToString(deviceSecret, Base64.NO_WRAP).toCharArray()
val effectiveMaster = CharArray(masterPassword.size + deviceSecretB64.size)
System.arraycopy(masterPassword, 0, effectiveMaster, 0, masterPassword.size)
System.arraycopy(deviceSecretB64, 0, effectiveMaster, masterPassword.size, deviceSecretB64.size)
val salt = "$serviceName:$counter".toByteArray(Charsets.UTF_8)
val passwordBytes = String(effectiveMaster).toByteArray(Charsets.UTF_8)
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withSalt(salt)
.withMemoryAsKB(ARGON2_MEMORY)
.withIterations(ARGON2_ITERATIONS)
.withParallelism(ARGON2_PARALLELISM)
.build()
val output = ByteArray(ARGON2_OUTPUT_LEN)
val generator = Argon2BytesGenerator()
generator.init(params)
generator.generateBytes(passwordBytes, output)
var cursor = 0
fun nextByte(): Int = (output[cursor++ % ARGON2_OUTPUT_LEN].toInt() and 0xFF)
// Build syllable-based words from Argon2id output bytes
val words = (0 until options.wordCount).map {
val syllableCount = 2 + (nextByte() % 2) // 2 or 3 syllables per word
buildString {
repeat(syllableCount) {
val idx = ((nextByte() shl 8) or nextByte()) % syllables.size
append(syllables[idx])
}
}
}
val sb = StringBuilder(words.joinToString(" "))
if (options.addSpecialChars) {
val specialChar = SPECIAL_CHARS[nextByte() % SPECIAL_CHARS.size]
val uppercaseChar = 'A' + (nextByte() % 26)
val digit = '0' + (nextByte() % 10)
sb.insert(nextByte() % (sb.length + 1), specialChar)
sb.insert(nextByte() % (sb.length + 1), uppercaseChar)
sb.insert(nextByte() % (sb.length + 1), digit)
}
// Wipe sensitive data
effectiveMaster.fill('\u0000')
deviceSecretB64.fill('\u0000')
passwordBytes.fill(0)
output.fill(0)
return sb.toString()
}
}

View File

@ -1,132 +0,0 @@
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 generatedPassword: String = "",
val clipboardTimerSeconds: Int = 0,
val showRotateWarning: Boolean = false,
val serviceHistory: List<String> = emptyList(),
) {
// 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 &&
generatedPassword == other.generatedPassword &&
clipboardTimerSeconds == other.clipboardTimerSeconds &&
showRotateWarning == other.showRotateWarning &&
serviceHistory == other.serviceHistory
}
override fun hashCode(): Int {
var result = serviceName.hashCode()
result = 31 * result + masterPassword.contentHashCode()
result = 31 * result + masterPasswordVisible.hashCode()
result = 31 * result + generatedPassword.hashCode()
result = 31 * result + clipboardTimerSeconds
result = 31 * result + showRotateWarning.hashCode()
result = 31 * result + serviceHistory.hashCode()
return result
}
}
class DeterministicViewModel(app: Application) : AndroidViewModel(app) {
private val deviceSecretManager = DeviceSecretManager(app)
val historyRepository = ServiceHistoryRepository(app)
val counterRepository = ServiceCounterRepository(app)
private val _state = MutableStateFlow(DeterministicState())
val state: StateFlow<DeterministicState> = _state.asStateFlow()
private var clipboardClearJob: Job? = null
init {
_state.update { it.copy(serviceHistory = historyRepository.getNames()) }
}
fun updateServiceName(name: String) = _state.update { it.copy(serviceName = name.lowercase()) }
fun updateMasterPassword(pw: CharArray) = _state.update { it.copy(masterPassword = pw) }
fun toggleMasterPasswordVisible() =
_state.update { it.copy(masterPasswordVisible = !it.masterPasswordVisible) }
fun selectService(name: String) = _state.update { it.copy(serviceName = name.lowercase()) }
fun generate() {
val s = _state.value
if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return
val counter = counterRepository.getCounter(s.serviceName)
val deviceSecret = deviceSecretManager.getOrCreateSecret()
val password = DeterministicPasswordGenerator.generate(
masterPassword = s.masterPassword,
deviceSecret = deviceSecret,
serviceName = s.serviceName,
counter = counter,
options = SyllableOptions(),
)
historyRepository.upsert(s.serviceName)
counterRepository.upsert(s.serviceName, counter)
_state.update { it.copy(generatedPassword = password, serviceHistory = historyRepository.getNames()) }
}
fun rotatePassword() {
val s = _state.value
if (s.serviceName.isBlank() || s.masterPassword.isEmpty()) return
val newCounter = counterRepository.getCounter(s.serviceName) + 1
counterRepository.upsert(s.serviceName, newCounter)
val deviceSecret = deviceSecretManager.getOrCreateSecret()
val password = DeterministicPasswordGenerator.generate(
masterPassword = s.masterPassword,
deviceSecret = deviceSecret,
serviceName = s.serviceName,
counter = newCounter,
options = SyllableOptions(),
)
_state.update { it.copy(generatedPassword = password, showRotateWarning = true) }
}
fun dismissRotateWarning() = _state.update { it.copy(showRotateWarning = false) }
fun copyAndScheduleClear(context: Context) {
val password = _state.value.generatedPassword
if (password.isEmpty()) return
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 getDeviceSecret(): ByteArray = deviceSecretManager.exportSecret()
fun importDeviceSecret(secret: ByteArray) = deviceSecretManager.importSecret(secret)
}

View File

@ -1,41 +0,0 @@
package cz.bugsy.passwordzebra.deterministic
import android.content.Context
import android.util.Base64
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import java.security.SecureRandom
class DeviceSecretManager(context: Context) {
private val prefs by lazy {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
"device_secret_prefs",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun getOrCreateSecret(): ByteArray {
val existing = prefs.getString(KEY_SECRET, null)
if (existing != null) {
return Base64.decode(existing, Base64.NO_WRAP)
}
val secret = SecureRandom.getSeed(32)
prefs.edit().putString(KEY_SECRET, Base64.encodeToString(secret, Base64.NO_WRAP)).apply()
return secret
}
fun importSecret(secret: ByteArray) {
prefs.edit().putString(KEY_SECRET, Base64.encodeToString(secret, Base64.NO_WRAP)).apply()
}
fun exportSecret(): ByteArray = getOrCreateSecret()
companion object {
private const val KEY_SECRET = "device_secret"
}
}

View File

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

View File

@ -1,41 +0,0 @@
package cz.bugsy.passwordzebra.deterministic
import android.content.Context
class ServiceHistoryRepository(context: Context) {
private val prefs = context.getSharedPreferences("service_history", Context.MODE_PRIVATE)
fun getNames(): List<String> {
val raw = prefs.getString(KEY_SERVICES, "") ?: ""
if (raw.isBlank()) return emptyList()
return raw.split("|").map { entry ->
// Migration: strip counter from old "name:counter" format
val colonIdx = entry.lastIndexOf(':')
if (colonIdx >= 0 && entry.substring(colonIdx + 1).toIntOrNull() != null) {
entry.substring(0, colonIdx)
} else {
entry
}
}.filter { it.isNotBlank() }
}
fun upsert(name: String) {
val names = getNames().toMutableList()
names.remove(name)
names.add(0, name)
save(names)
}
fun delete(name: String) {
save(getNames().filter { it != name })
}
private fun save(names: List<String>) {
prefs.edit().putString(KEY_SERVICES, names.joinToString("|")).apply()
}
companion object {
private const val KEY_SERVICES = "services"
}
}

View File

@ -1,30 +0,0 @@
package cz.bugsy.passwordzebra.ui.components
import android.widget.NumberPicker
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
@Composable
fun NumberPickerView(
value: Int,
minValue: Int,
maxValue: Int,
onValueChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
AndroidView(
factory = { ctx ->
NumberPicker(ctx).apply {
this.minValue = minValue
this.maxValue = maxValue
this.value = value
setOnValueChangedListener { _, _, new -> onValueChange(new) }
}
},
update = { picker ->
if (picker.value != value) picker.value = value
},
modifier = modifier,
)
}

View File

@ -1,93 +0,0 @@
package cz.bugsy.passwordzebra.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import java.security.MessageDigest
/**
* Renders a small symmetric 5×5 identicon derived from the SHA-256 hash of [password].
* Color hue, saturation, and lightness are taken from hash bytes so that every unique
* password produces a visually distinct pattern that is easy to memorise.
*
* Shown as a plain surface-variant box when [password] is empty.
*/
@Composable
fun PasswordFingerprint(
password: CharArray,
modifier: Modifier = Modifier,
size: Dp = 52.dp,
) {
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
val outline = MaterialTheme.colorScheme.outline
// Recompute hash only when the actual password content changes.
val hash: ByteArray? = if (password.isEmpty()) null else remember(String(password)) {
MessageDigest.getInstance("SHA-256")
.digest(String(password).toByteArray(Charsets.UTF_8))
}
val fillColor: Color? = hash?.let {
val hue = (it[0].toInt() and 0xFF) * 360f / 255f
val sat = 0.50f + (it[1].toInt() and 0xFF) / 255f * 0.35f
val lit = 0.38f + (it[2].toInt() and 0xFF) / 255f * 0.18f
Color.hsl(hue, sat, lit)
}
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(8.dp))
.background(surfaceVariant)
.border(1.dp, outline.copy(alpha = 0.35f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
if (hash != null && fillColor != null) {
val canvasSize = size * 0.80f // leave a small margin inside the box
Canvas(modifier = Modifier.size(canvasSize)) {
val gridSize = 5
val cellW = this.size.width / gridSize
val cellH = this.size.height / gridSize
val gap = 1.5f
// Only decide cols 0..2; mirror cols 0..1 to cols 4..3.
// Col 2 is the centre column (not mirrored).
for (row in 0 until gridSize) {
for (col in 0..2) {
val bitIndex = row * 3 + col
val filled = (hash[bitIndex / 8].toInt() ushr (bitIndex % 8)) and 1 == 1
if (filled) {
drawRect(
color = fillColor,
topLeft = Offset(col * cellW + gap, row * cellH + gap),
size = Size(cellW - gap * 2, cellH - gap * 2),
)
if (col < 2) {
drawRect(
color = fillColor,
topLeft = Offset((4 - col) * cellW + gap, row * cellH + gap),
size = Size(cellW - gap * 2, cellH - gap * 2),
)
}
}
}
}
}
}
}
}

View File

@ -1,202 +0,0 @@
package cz.bugsy.passwordzebra.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.platform.LocalDensity
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.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
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.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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)
private val tabRouteOrder = listOf(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)) },
)
}
}
},
) { innerPadding ->
val density = LocalDensity.current
val imeVisible = WindowInsets.ime.getBottom(density) > 0
val bottomPadding = if (imeVisible) 0.dp else innerPadding.calculateBottomPadding()
Box(modifier = Modifier.padding(bottom = bottomPadding)) {
NavHost(navController = navController, startDestination = ROUTE_WORD_GENERATOR) {
composable(
ROUTE_WORD_GENERATOR,
enterTransition = {
val fromIdx = tabRouteOrder.indexOf(initialState.destination.route)
val toIdx = tabRouteOrder.indexOf(ROUTE_WORD_GENERATOR)
if (fromIdx != -1) slideInHorizontally(tween(200)) { if (toIdx > fromIdx) it else -it }
else fadeIn(tween(200))
},
exitTransition = {
val toIdx = tabRouteOrder.indexOf(targetState.destination.route)
if (toIdx != -1) slideOutHorizontally(tween(200)) { if (toIdx > 0) -it else it }
else fadeOut(tween(150))
},
popEnterTransition = {
val fromIdx = tabRouteOrder.indexOf(initialState.destination.route)
val toIdx = tabRouteOrder.indexOf(ROUTE_WORD_GENERATOR)
if (fromIdx != -1) slideInHorizontally(tween(200)) { if (toIdx > fromIdx) it else -it }
else fadeIn(tween(200))
},
popExitTransition = { fadeOut(tween(150)) },
) {
WordGeneratorScreen(
onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) },
)
}
composable(
ROUTE_DETERMINISTIC,
enterTransition = {
val fromIdx = tabRouteOrder.indexOf(initialState.destination.route)
val toIdx = tabRouteOrder.indexOf(ROUTE_DETERMINISTIC)
if (fromIdx != -1) slideInHorizontally(tween(200)) { if (toIdx > fromIdx) it else -it }
else fadeIn(tween(200))
},
exitTransition = {
val toIdx = tabRouteOrder.indexOf(targetState.destination.route)
if (toIdx != -1) slideOutHorizontally(tween(200)) { if (toIdx > 1) -it else it }
else fadeOut(tween(150))
},
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = {
val toIdx = tabRouteOrder.indexOf(targetState.destination.route)
if (toIdx != -1) slideOutHorizontally(tween(200)) { if (toIdx > 1) -it else it }
else fadeOut(tween(150))
},
) {
DeterministicScreen(
viewModel = detViewModel,
onNavigateToHistory = { navController.navigate(ROUTE_SERVICE_HISTORY) },
onNavigateToSettings = { navController.navigate(ROUTE_SETTINGS) },
onNavigateToExport = { navController.navigate(ROUTE_EXPORT_IMPORT) },
)
}
composable(
ROUTE_SERVICE_HISTORY,
enterTransition = { slideInHorizontally(tween(300)) { it } },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) {
ServiceHistoryScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() },
onServiceSelected = { _ -> navController.popBackStack() },
)
}
composable(
ROUTE_SETTINGS,
enterTransition = { slideInHorizontally(tween(300)) { it } },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToExportImport = { navController.navigate(ROUTE_EXPORT_IMPORT) },
)
}
composable(
ROUTE_EXPORT_IMPORT,
enterTransition = { slideInHorizontally(tween(300)) { it } },
exitTransition = { fadeOut(tween(200)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(tween(300)) { it } },
) {
ExportImportScreen(
viewModel = detViewModel,
onNavigateBack = { navController.popBackStack() },
)
}
}
}
}
}

View File

@ -1,362 +0,0 @@
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
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.History
import androidx.compose.material.icons.filled.LockReset
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.activity.compose.BackHandler
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.ImeAction
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
import cz.bugsy.passwordzebra.ui.components.PasswordFingerprint
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeterministicScreen(
viewModel: DeterministicViewModel,
onNavigateToHistory: () -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToExport: () -> Unit,
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
BackHandler { (context as? android.app.Activity)?.finish() }
var showRotateConfirmDialog by remember { mutableStateOf(false) }
val masterPasswordFocus = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
if (state.showRotateWarning) {
AlertDialog(
onDismissRequest = { viewModel.dismissRotateWarning() },
icon = {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = {
Text(
text = stringResource(R.string.det_rotate_warning_title),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
)
},
text = {
Text(stringResource(R.string.det_rotate_warning_body))
},
confirmButton = {
Button(
onClick = {
viewModel.dismissRotateWarning()
onNavigateToExport()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.det_rotate_go_to_export))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissRotateWarning() }) {
Text(
text = stringResource(R.string.det_rotate_later),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
)
}
if (showRotateConfirmDialog) {
AlertDialog(
onDismissRequest = { showRotateConfirmDialog = false },
title = { Text(stringResource(R.string.det_rotate_confirm_title)) },
text = { Text(stringResource(R.string.det_rotate_confirm_body)) },
confirmButton = {
Button(
onClick = {
showRotateConfirmDialog = false
viewModel.rotatePassword()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.det_rotate_confirm_ok))
}
},
dismissButton = {
TextButton(onClick = { showRotateConfirmDialog = false }) {
Text(stringResource(R.string.det_rotate_confirm_cancel))
}
},
)
}
Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.imePadding(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(bottom = 88.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Spacer(modifier = Modifier.height(32.dp))
// Generated password box — always visible, even when empty
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp),
)
.clickable(enabled = state.generatedPassword.isNotEmpty()) {
viewModel.copyAndScheduleClear(context)
}
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
if (state.generatedPassword.isNotEmpty()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(
text = state.generatedPassword,
fontSize = 24.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
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,
)
}
}
}
}
}
// Inline rotate button — only shown when password is generated
if (state.generatedPassword.isNotEmpty()) {
TextButton(
onClick = { showRotateConfirmDialog = true },
modifier = Modifier.align(Alignment.End),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFFD600)),
) {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.det_rotate_inline_label))
}
}
// Service name with autocomplete from history
var expanded by remember { mutableStateOf(false) }
val filteredHistory = remember(state.serviceHistory, state.serviceName) {
if (state.serviceName.length < 3) emptyList()
else state.serviceHistory.filter {
it.startsWith(state.serviceName, ignoreCase = true)
}
}
val showDropdown = expanded && filteredHistory.size >= 2
ExposedDropdownMenuBox(
expanded = showDropdown,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
value = state.serviceName,
onValueChange = { viewModel.updateServiceName(it); expanded = true },
label = { Text(stringResource(R.string.det_service_name)) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = {
expanded = false
masterPasswordFocus.requestFocus()
},
),
)
ExposedDropdownMenu(
expanded = showDropdown,
onDismissRequest = { expanded = false },
) {
filteredHistory.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { viewModel.updateServiceName(name); expanded = false },
)
}
}
}
Spacer(Modifier.height(8.dp))
// Master password + visual fingerprint
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = String(state.masterPassword),
onValueChange = { viewModel.updateMasterPassword(it.toCharArray()) },
label = { Text(stringResource(R.string.det_master_password)) },
modifier = Modifier
.weight(1f)
.focusRequester(masterPasswordFocus),
singleLine = true,
visualTransformation = if (state.masterPasswordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
viewModel.generate()
},
),
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),
)
}
},
)
Spacer(Modifier.width(8.dp))
PasswordFingerprint(
password = state.masterPassword,
size = 56.dp,
)
}
Spacer(modifier = Modifier.height(4.dp))
} // end Column
// Button row: History | Generate | Settings — floats just above keyboard
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(start = 16.dp, top = 14.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(
onClick = onNavigateToHistory,
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Default.History, contentDescription = stringResource(R.string.det_history))
}
Spacer(Modifier.width(20.dp))
FilledIconButton(
onClick = { keyboardController?.hide(); viewModel.generate() },
modifier = Modifier.size(60.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Filled.LockReset,
contentDescription = stringResource(R.string.det_generate),
)
}
Spacer(Modifier.width(20.dp))
FilledTonalIconButton(
onClick = onNavigateToSettings,
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.det_settings))
}
}
} // end outer Box
}

View File

@ -1,228 +0,0 @@
package cz.bugsy.passwordzebra.ui.screens
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import org.json.JSONArray
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.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.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
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.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
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
private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportImportScreen(
viewModel: DeterministicViewModel,
onNavigateBack: () -> Unit,
) {
val context = LocalContext.current
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
fun launchImportAuth(secret: ByteArray) {
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.importDeviceSecret(secret)
}
},
)
BiometricPrompt.PromptInfo.Builder()
.setTitle(context.getString(R.string.det_import_confirm_title))
.setSubtitle(context.getString(R.string.det_import_biometric_subtitle))
.setAllowedAuthenticators(AUTH)
.build()
.let { prompt.authenticate(it) }
}
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
val rawValue = result.contents ?: return@rememberLauncherForActivityResult
val uri = Uri.parse(rawValue)
val secretParam = uri.getQueryParameter("secret") ?: ""
if (secretParam.isNotEmpty()) {
val decoded = try {
Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
} catch (_: Exception) { null }
if (decoded != null && decoded.size == 32) {
val servicesParam = uri.getQueryParameter("services")
if (servicesParam != null) {
try {
val json = String(Base64.decode(servicesParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
val arr = JSONArray(json)
for (i in 0 until arr.length()) {
val entry = arr.getJSONArray(i)
val name = entry.getString(0)
val counter = entry.getInt(1)
viewModel.counterRepository.upsert(name, counter)
}
} catch (_: Exception) { /* ignore malformed services */ }
}
launchImportAuth(decoded)
}
}
}
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)
})
}
}
}
}
@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 rotatedServices = viewModel.counterRepository.getAll().filter { it.second >= 2 }
val uri = if (rotatedServices.isEmpty()) {
"passwordzebra://backup?v=2&secret=$b64"
} else {
val json = JSONArray().apply {
rotatedServices.forEach { (name, counter) -> put(JSONArray().put(name).put(counter)) }
}.toString()
val servicesB64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
"passwordzebra://backup?v=2&secret=$b64&services=$servicesB64"
}
qrBitmap = generateQrBitmap(uri, 512)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.det_export_body),
style = MaterialTheme.typography.bodyMedium,
)
qrBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.det_export_qr_desc),
modifier = Modifier.size(280.dp),
)
}
}
}
@Composable
private fun ImportTab(onScan: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.det_import_body),
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = onScan, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.det_scan_qr))
}
}
}
private fun generateQrBitmap(content: String, size: Int): Bitmap? {
return try {
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, size, size)
val width = bitMatrix.width
val height = bitMatrix.height
val pixels = IntArray(width * height) { i ->
val x = i % width
val y = i / width
if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
} catch (_: Exception) {
null
}
}

View File

@ -1,110 +0,0 @@
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.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) -> Unit,
) {
// Read names on each recomposition so deletes reflect immediately
var services by remember { mutableStateOf(viewModel.historyRepository.getNames()) }
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 }) { name ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.selectService(name)
onServiceSelected(name)
},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
viewModel.historyRepository.delete(name)
services = viewModel.historyRepository.getNames()
}) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.det_delete_service),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
}
}

View File

@ -1,179 +0,0 @@
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.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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
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.BuildConfig
import cz.bugsy.passwordzebra.R
private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToExportImport: () -> Unit,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
fun launchExportImportAuth() {
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) {
onNavigateToExportImport()
}
},
)
BiometricPrompt.PromptInfo.Builder()
.setTitle(context.getString(R.string.det_export_import_title))
.setSubtitle(context.getString(R.string.det_export_locked_body))
.setAllowedAuthenticators(AUTH)
.build()
.let { prompt.authenticate(it) }
}
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))
// 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 = { launchExportImportAuth() },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.det_export_import_title))
}
HorizontalDivider()
// Privacy
Text(
text = stringResource(R.string.privacy_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.privacy_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// About / License
Text(
text = stringResource(R.string.about_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_license_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_license_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_oss_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.about_oss_body),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider()
// Contributing
Text(
text = stringResource(R.string.about_contributing_title),
style = MaterialTheme.typography.titleSmall,
)
TextButton(
onClick = { uriHandler.openUri("https://git.bugsy.cz/beval/password_zebra") },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.about_source_code))
}
TextButton(
onClick = { uriHandler.openUri("https://git.bugsy.cz/beval/password_zebra/issues") },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.about_issues))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@ -1,321 +0,0 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
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(onNavigateToSettings: () -> Unit) {
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))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.width(20.dp))
GenerateButton(onClick = onGenerate)
Spacer(modifier = Modifier.width(20.dp))
FilledTonalIconButton(
onClick = onNavigateToSettings,
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(R.string.det_settings),
)
}
}
}
}
} 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))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 14.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.width(20.dp))
GenerateButton(onClick = onGenerate)
Spacer(modifier = Modifier.width(20.dp))
FilledTonalIconButton(
onClick = onNavigateToSettings,
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(R.string.det_settings),
)
}
}
}
}
}
}
@Composable
private fun PasswordBox(password: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = password,
fontSize = 24.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun ControlsPanel(
wordCount: Int,
withoutSpaces: Boolean,
addSpecialChars: Boolean,
onWordCountChange: (Int) -> Unit,
onWithoutSpacesChange: (Boolean) -> Unit,
onAddSpecialCharsChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
painter = painterResource(R.drawable.pw_straighten),
contentDescription = stringResource(R.string.image_length_desc),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.textview_password_length),
modifier = Modifier.weight(1f),
)
NumberPickerView(
value = wordCount,
minValue = 1,
maxValue = 10,
onValueChange = onWordCountChange,
modifier = Modifier.widthIn(min = 80.dp),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
painter = painterResource(R.drawable.pw_letter_spacing),
contentDescription = stringResource(R.string.image_wospaces),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.switch_without_spaces),
modifier = Modifier.weight(1f),
)
Switch(
checked = withoutSpaces,
onCheckedChange = onWithoutSpacesChange,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
painter = painterResource(R.drawable.pw_special_character),
contentDescription = stringResource(R.string.image_specialchars),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.switch_special_characters),
modifier = Modifier.weight(1f),
)
Switch(
checked = addSpecialChars,
onCheckedChange = onAddSpecialCharsChange,
)
}
}
}
@Composable
private fun GenerateButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
FilledIconButton(
onClick = onClick,
modifier = modifier.size(60.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
painter = painterResource(R.drawable.pw_generate),
contentDescription = stringResource(R.string.button_generate_description),
)
}
}
private fun generatePassword(wordCount: Int, addSpecialChars: Boolean): String {
return buildString {
repeat(wordCount) {
append(PasswordGenerator.generateRandomWord())
append(" ")
}
}.let { password ->
if (addSpecialChars) {
PasswordGenerator.addSpecialCharacters(password)
} else {
password
}
}.trim()
}
private fun copyToClipboard(context: Context, text: String, withoutSpaces: Boolean) {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipText = if (withoutSpaces) text.filter { !it.isWhitespace() } else text
val clipData = ClipData.newPlainText("Password", clipText)
clipboardManager.setPrimaryClip(clipData)
}

View File

@ -1,92 +0,0 @@
package cz.bugsy.passwordzebra.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF286C2A),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFABF5A3),
onPrimaryContainer = Color(0xFF002203),
secondary = Color(0xFF00639A),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFCEE5FF),
onSecondaryContainer = Color(0xFF001D32),
tertiary = Color(0xFF38656A),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFBCEBF0),
onTertiaryContainer = Color(0xFF002023),
error = Color(0xFFBA1A1A),
errorContainer = Color(0xFFFFDAD6),
onError = Color(0xFFFFFFFF),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFCFDF6),
onBackground = Color(0xFF1A1C19),
surface = Color(0xFFFCFDF6),
onSurface = Color(0xFF1A1C19),
surfaceVariant = Color(0xFFDEE5D8),
onSurfaceVariant = Color(0xFF424940),
outline = Color(0xFF72796F),
inverseOnSurface = Color(0xFFF1F1EB),
inverseSurface = Color(0xFF2F312D),
inversePrimary = Color(0xFF90D889),
surfaceTint = Color(0xFF286C2A),
outlineVariant = Color(0xFFC2C9BD),
scrim = Color(0xFF000000),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF90D889),
onPrimary = Color(0xFF003909),
primaryContainer = Color(0xFF085314),
onPrimaryContainer = Color(0xFFABF5A3),
secondary = Color(0xFF96CCFF),
onSecondary = Color(0xFF003353),
secondaryContainer = Color(0xFF004A75),
onSecondaryContainer = Color(0xFFCEE5FF),
tertiary = Color(0xFFA0CFD4),
onTertiary = Color(0xFF00363B),
tertiaryContainer = Color(0xFF1E4D52),
onTertiaryContainer = Color(0xFFBCEBF0),
error = Color(0xFFFFB4AB),
errorContainer = Color(0xFF93000A),
onError = Color(0xFF690005),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C19),
onBackground = Color(0xFFE2E3DD),
surface = Color(0xFF1A1C19),
onSurface = Color(0xFFE2E3DD),
surfaceVariant = Color(0xFF424940),
onSurfaceVariant = Color(0xFFC2C9BD),
outline = Color(0xFF8C9388),
inverseOnSurface = Color(0xFF1A1C19),
inverseSurface = Color(0xFFE2E3DD),
inversePrimary = Color(0xFF286C2A),
surfaceTint = Color(0xFF90D889),
outlineVariant = Color(0xFF424940),
scrim = Color(0xFF000000),
)
@Composable
fun PasswordZebraTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, content = content)
}

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M480,800q-134,0 -227,-93t-93,-227q0,-134 93,-227t227,-93q69,0 132,28.5T720,270v-110h80v280L520,440v-80h168q-32,-56 -87.5,-88T480,240q-100,0 -170,70t-70,170q0,100 70,170t170,70q77,0 139,-44t87,-116h84q-28,106 -114,173t-196,67Z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M160,800v-640h80v640h-80ZM720,800v-640h80v640h-80ZM294,680l150,-400h72l150,400h-69l-36,-102L399,578l-36,102h-69ZM420,520h120l-58,-166h-4l-58,166Z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M120,840v-120h200q-84,-45 -132,-125t-48,-175q0,-142 99,-241t241,-99q142,0 241,99t99,241q0,95 -48,175T640,720h200v120L520,840v-204q78,-14 129,-75t51,-141q0,-92 -64,-156t-156,-64q-92,0 -156,64t-64,156q0,80 51,141t129,75v204L120,840Z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFFFF" android:pathData="M160,720q-33,0 -56.5,-23.5T80,640v-320q0,-33 23.5,-56.5T160,240h640q33,0 56.5,23.5T880,320v320q0,33 -23.5,56.5T800,720L160,720ZM160,640h640v-320L680,320v160h-80v-160h-80v160h-80v-160h-80v160h-80v-160L160,320v320ZM280,480h80,-80ZM440,480h80,-80ZM600,480h80,-80ZM480,480Z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke android:width="1dip" android:color="#4fa5d5"/>
<corners android:radius="8dp" />
</shape>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M480,800q-134,0 -227,-93t-93,-227q0,-134 93,-227t227,-93q69,0 132,28.5T720,270v-110h80v280L520,440v-80h168q-32,-56 -87.5,-88T480,240q-100,0 -170,70t-70,170q0,100 70,170t170,70q77,0 139,-44t87,-116h84q-28,106 -114,173t-196,67Z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M160,800v-640h80v640h-80ZM720,800v-640h80v640h-80ZM294,680l150,-400h72l150,400h-69l-36,-102L399,578l-36,102h-69ZM420,520h120l-58,-166h-4l-58,166Z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M120,840v-120h200q-84,-45 -132,-125t-48,-175q0,-142 99,-241t241,-99q142,0 241,99t99,241q0,95 -48,175T640,720h200v120L520,840v-204q78,-14 129,-75t51,-141q0,-92 -64,-156t-156,-64q-92,0 -156,64t-64,156q0,80 51,141t129,75v204L120,840Z"/>
</vector>

View File

@ -1,5 +0,0 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="960" android:viewportWidth="960"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M160,720q-33,0 -56.5,-23.5T80,640v-320q0,-33 23.5,-56.5T160,240h640q33,0 56.5,23.5T880,320v320q0,33 -23.5,56.5T800,720L160,720ZM160,640h640v-320L680,320v160h-80v-160h-80v160h-80v-160h-80v160h-80v-160L160,320v320ZM280,480h80,-80ZM440,480h80,-80ZM600,480h80,-80ZM480,480Z"/>
</vector>

View File

@ -0,0 +1,116 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!--
<TextView
android:id="@+id/titleTextView"
android:layout_width="409dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:gravity="center"
android:text="@string/app_name"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
-->
<!--
<ImageView
android:id="@+id/iconImageView"
android:layout_width="161dp"
android:layout_height="126dp"
android:layout_marginTop="32dp"
android:contentDescription="@string/info_text"
android:src="@drawable/zebra_shield"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleTextView"
app:tint="@color/design_default_color_primary" />
-->
<TextView
android:id="@+id/passwordTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:minHeight="150dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="10dp"
android:gravity="center"
android:padding="16dp"
android:text=""
android:background="@drawable/password_background"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/passwordLengthLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginBottom="76dp"
android:text="@string/textview_password_length"
app:layout_constraintBottom_toTopOf="@id/switchWithoutSpaces"
app:layout_constraintStart_toStartOf="parent" />
<NumberPicker
android:id="@+id/passwordLengthPicker"
android:layout_width="69dp"
android:layout_height="178dp"
android:layout_marginStart="81dp"
android:layout_marginEnd="72dp"
app:layout_constraintBottom_toTopOf="@+id/switchWithoutSpaces"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@+id/passwordLengthLabel"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchWithoutSpaces"
android:layout_width="267dp"
android:layout_height="48dp"
android:text="@string/switch_without_spaces"
app:layout_constraintBottom_toTopOf="@id/switchSpecialChars"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchSpecialChars"
android:layout_width="267dp"
android:layout_height="48dp"
android:layout_marginBottom="36dp"
android:text="@string/switch_special_characters"
app:layout_constraintBottom_toTopOf="@id/generateButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/generateButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="44dp"
android:text="@string/generate_button_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="20dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,80 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Password Zebra</string> <string name="app_name">Password Zebra</string>
<string name="button_generate_text">Generovat</string> <string name="generate_button_text">Generovat</string>
<string name="info_text">Dotekem zkopírujete text do schránky</string> <string name="info_text">Dotekem zkopírujete text do schránky</string>
<string name="textview_password_length">Délka hesla</string> <string name="textview_password_length">Délka hesla</string>
<string name="switch_without_spaces">Kopírovat bez mezer</string> <string name="switch_without_spaces">Kopírovat bez mezer</string>
<string name="switch_special_characters">Přidat speciální znaky</string> <string name="switch_special_characters">Přidat speciální znaky</string>
<string name="button_generate_description">Vytvořit nové heslo</string>
<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_options">Možnosti</string>
<string name="det_generate">Generovat</string>
<string name="det_tap_to_copy">Dotekem zkopírujte</string>
<string name="det_history">Historie služeb</string>
<string name="det_settings">Nastavení</string>
<string name="det_rotate_password">Heslo kompromitováno — rotovat</string>
<string name="det_rotate_warning_title">⚠ Aktualizujte QR zálohu — ihned!</string>
<string name="det_rotate_warning_body">Pro tuto službu bylo vygenerováno nové heslo.\n\nVáš záložní QR kód tuto rotaci nezachycuje. Pokud ztratíte zařízení bez aktualizace QR kódu, TOTO HESLO BUDE TRVALE ZTRACENO — žádným jiným způsobem ho nelze obnovit.\n\nOstatní hesla nejsou dotčena.</string>
<string name="det_rotate_go_to_export">Aktualizovat QR zálohu nyní</string>
<string name="det_rotate_later">Udělám to později (riskantní)</string>
<string name="det_rotate_inline_label">Bylo heslo kompromitováno?</string>
<string name="det_rotate_confirm_title">Rotovat heslo?</string>
<string name="det_rotate_confirm_body">Tato akce zvýší čítač pro tuto službu. Předchozí heslo již nebude generováno.</string>
<string name="det_rotate_confirm_ok">Rotovat</string>
<string name="det_rotate_confirm_cancel">Zrušit</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 obnovu trezoru. Obsahuje device secret a čítače rotovaných služeb. Uchovejte jej v tajnosti kdokoli s tímto kódem a vaším hlavním heslem může znovu vygenerovat všechna vaše hesla.</string>
<string name="det_export_qr_desc">QR kód pro export device secret</string>
<string name="det_import_body">Naskenujte QR kód exportovaný z jiného zařízení pro import device secret.</string>
<string name="det_scan_qr">Naskenovat QR kód</string>
<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_argon2_info_title">Parametry Argon2id</string>
<string name="det_argon2_info_body">Paměť: 64 MB · Iterace: 3 · Paralelismus: 1 · Výstup: 64 bajtů</string>
<string name="det_export_locked_body">Ověřte se pro zobrazení QR zálohy vašeho device secret.</string>
<string name="det_import_biometric_subtitle">Potvrzení nahrazení device secret</string>
<!-- Ochrana soukromí -->
<string name="privacy_title">Ochrana soukromí</string>
<string name="privacy_body">Password Zebra funguje zcela offline. Žádná data nejsou sbírána, přenášena ani ukládána mimo vaše zařízení. Přístup ke kameře slouží výhradně k lokálnímu skenování QR kódů. Biometrické ověření je plně zajišťováno operačním systémem Android; tato aplikace nemá přístup k žádným biometrickým datům.</string>
<!-- O aplikaci / Licence -->
<string name="about_title">O aplikaci</string>
<string name="about_version">Verze %s</string>
<string name="about_license_title">Licence</string>
<string name="about_license_body">Tato aplikace je svobodný software šířený pod licencí GNU General Public License verze 3 (GPL-3.0-or-later). Můžete ji šířit a/nebo upravovat za podmínek GPL vydané Free Software Foundation, verze 3 nebo (dle vaší volby) jakékoli novější verze.\n\nTento program je šířen BEZ ZÁRUKY; bez jakékoli záruky prodejnosti nebo vhodnosti pro konkrétní účel.</string>
<string name="about_oss_title">Open-source komponenty</string>
<string name="about_oss_body">Bouncy Castle (MIT) · ZXing / ZXing Android Embedded (Apache 2.0) · Jetpack Compose, AndroidX, Material 3 (Apache 2.0)</string>
<string name="about_contributing_title">Přispívání</string>
<string name="about_source_code">Zdrojový kód</string>
<string name="about_issues">Nahlásit problém</string>
</resources> </resources>

View File

@ -1,15 +1,21 @@
<resources> <resources>
<style name="Theme.PasswordZebra" parent="Theme.Material3.Dark.NoActionBar"> <!-- <style name="Theme.PasswordZebra" parent="Theme.AppCompat.DayNight.DarkActionBar"> -->
<style name="Theme.PasswordZebra" parent="Theme.Material3.DayNight">
<!-- <style name="Theme.PasswordZebra" parent="Theme.Material3.DayNight.NoActionBar"> -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<!-- status bar font color --> <!-- status bar font color -->
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<!-- true = status bar semi-transparent --> <!-- true = status bar semi-transparent -->
<item name="android:windowTranslucentStatus">false</item> <item name="android:windowTranslucentStatus">false</item>
<!-- action bar -->
<!-- defined in themes_overlays - item name="actionBarStyle" -->
<!-- navigation bar = bottom bar with navigation buttons --> <!-- navigation bar = bottom bar with navigation buttons -->
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightNavigationBar">false</item> <item name="android:windowLightNavigationBar">true</item>
<item name="android:windowTranslucentNavigation">false</item> <item name="android:windowTranslucentNavigation">false</item>
<item name="colorPrimary">@color/md_theme_dark_primary</item> <item name="colorPrimary">@color/md_theme_dark_primary</item>
@ -28,7 +34,7 @@
<item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item> <item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
<item name="colorOnError">@color/md_theme_dark_onError</item> <item name="colorOnError">@color/md_theme_dark_onError</item>
<item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item> <item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
<item name="android:colorBackground">@color/md_theme_dark_background</item> <item name="android:colorBackground">@color/md_theme_dark_onBackground</item>
<item name="colorSurface">@color/md_theme_dark_surface</item> <item name="colorSurface">@color/md_theme_dark_surface</item>
<item name="colorOnSurface">@color/md_theme_dark_onSurface</item> <item name="colorOnSurface">@color/md_theme_dark_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item> <item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item>
@ -38,4 +44,9 @@
<item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item> <item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
<item name="colorPrimaryInverse">@color/md_theme_dark_inversePrimary</item> <item name="colorPrimaryInverse">@color/md_theme_dark_inversePrimary</item>
</style> </style>
<style name="MyActionBar" parent="@style/Widget.AppCompat.ActionBar">
<item name="background">@android:color/transparent</item>
</style>
</resources> </resources>

View File

@ -1,79 +1,8 @@
<resources> <resources>
<string name="app_name">Password Zebra</string> <string name="app_name">Password Zebra</string>
<string name="button_generate_text">Generate</string> <string name="generate_button_text">Generate</string>
<string name="info_text">Tap to copy password to clipboard</string> <string name="info_text">Tap to copy password to clipboard</string>
<string name="textview_password_length">Password Length</string> <string name="textview_password_length">Password Length</string>
<string name="switch_without_spaces">Copy Without Spaces</string> <string name="switch_without_spaces">Copy Without Spaces</string>
<string name="switch_special_characters">Add Special Characters</string> <string name="switch_special_characters">Add Special Characters</string>
<string name="button_generate_description">Generate new password</string>
<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_options">Options</string>
<string name="det_generate">Generate</string>
<string name="det_tap_to_copy">Tap to copy</string>
<string name="det_history">Service history</string>
<string name="det_settings">Settings</string>
<string name="det_rotate_password">Password Compromised — Rotate</string>
<string name="det_rotate_warning_title">⚠ Update Your QR Backup — Now!</string>
<string name="det_rotate_warning_body">A new password has been generated for this service.\n\nYour QR backup no longer reflects this rotation. If you lose this device without updating the QR code, THIS PASSWORD WILL BE PERMANENTLY LOST — it cannot be recovered any other way.\n\nYour other passwords are not affected.</string>
<string name="det_rotate_go_to_export">Update QR Backup Now</string>
<string name="det_rotate_later">I\'ll do it later (risky)</string>
<string name="det_rotate_inline_label">Password compromised?</string>
<string name="det_rotate_confirm_title">Rotate password?</string>
<string name="det_rotate_confirm_body">This will increment the counter for this service. The previous password will no longer be generated.</string>
<string name="det_rotate_confirm_ok">Rotate</string>
<string name="det_rotate_confirm_cancel">Cancel</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 restore your vault. It contains your device secret and any rotated service counters. Keep it private — anyone with this code and your master password can regenerate all your passwords.</string>
<string name="det_export_qr_desc">QR code for device secret export</string>
<string name="det_import_body">Scan a QR code exported from another device to import its device secret.</string>
<string name="det_scan_qr">Scan QR Code</string>
<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_argon2_info_title">Argon2id Parameters</string>
<string name="det_argon2_info_body">Memory: 64 MB · Iterations: 3 · Parallelism: 1 · Output: 64 bytes</string>
<string name="det_export_locked_body">Authenticate to view your device secret QR backup.</string>
<string name="det_import_biometric_subtitle">Confirm replacing your device secret</string>
<!-- Privacy -->
<string name="privacy_title">Privacy</string>
<string name="privacy_body">Password Zebra operates entirely offline. No data is collected, transmitted, or stored outside your device. Camera access is used solely to scan QR codes locally. Biometric authentication is handled entirely by the Android operating system and no biometric data is accessible to this app.</string>
<!-- About / License -->
<string name="about_title">About</string>
<string name="about_version">Version %s</string>
<string name="about_license_title">License</string>
<string name="about_license_body">This app is free software licensed under the GNU General Public License v3.0 (GPL-3.0-or-later). You may redistribute and/or modify it under the terms of the GPL as published by the Free Software Foundation, version 3 or (at your option) any later version.\n\nThis program is distributed WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.</string>
<string name="about_oss_title">Open-source components</string>
<string name="about_oss_body">Bouncy Castle (MIT) · ZXing / ZXing Android Embedded (Apache 2.0) · Jetpack Compose, AndroidX, Material 3 (Apache 2.0)</string>
<string name="about_contributing_title">Contributing</string>
<string name="about_source_code">Source code</string>
<string name="about_issues">Report an issue</string>
</resources> </resources>

View File

@ -1,6 +1,8 @@
<resources> <resources>
<style name="Theme.PasswordZebra" parent="Theme.Material3.Light.NoActionBar"> <style name="Theme.PasswordZebra" parent="Theme.Material3.Light">
<!-- <style name="Theme.PasswordZebra" parent="Theme.Material3.Light.NoActionBar"> -->
<!-- statusbar background color --> <!-- statusbar background color -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
@ -15,4 +17,9 @@
<item name="android:windowLightNavigationBar">true</item> <item name="android:windowLightNavigationBar">true</item>
<item name="android:windowTranslucentNavigation">false</item> <item name="android:windowTranslucentNavigation">false</item>
</style> </style>
<style name="MyActionBar" parent="@style/Widget.AppCompat.ActionBar">
<item name="background">@android:color/transparent</item>
</style>
</resources> </resources>

View File

@ -1,3 +1,6 @@
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme.Overlay" parent="ThemeOverlay.Material3.DynamicColors.DayNight" /> <style name="AppTheme.Overlay" parent="ThemeOverlay.Material3.DynamicColors.DayNight">
<item name="actionBarStyle">@style/MyActionBar</item>
<!-- <item name="actionBarStyle">@style/Widget.AppCompat.ActionBar</item> -->
</style>
</resources> </resources>

View File

@ -1,86 +0,0 @@
package cz.bugsy.passwordzebra
import org.junit.Assert.*
import org.junit.Test
import kotlin.random.Random
class PasswordGenerationTest {
// generateRandomWord tests
@Test
fun generateRandomWord_returnsNonEmptyString() {
val word = PasswordGenerator.generateRandomWord()
assertTrue(word.isNotEmpty())
}
@Test
fun generateRandomWord_consistsOnlyOfLowercaseLettersOrApostrophe() {
repeat(20) {
val word = PasswordGenerator.generateRandomWord()
assertTrue("Word '$word' contains unexpected characters",
word.all { it.isLowerCase() || it == '\'' })
}
}
@Test
fun generateRandomWord_withSameSeed_returnsSameResult() {
val word1 = PasswordGenerator.generateRandomWord(Random(42))
val word2 = PasswordGenerator.generateRandomWord(Random(42))
assertEquals(word1, word2)
}
@Test
fun generateRandomWord_withDifferentSeeds_canReturnDifferentResults() {
val words = (1..10).map { PasswordGenerator.generateRandomWord(Random(it.toLong())) }.toSet()
assertTrue("Expected multiple distinct words, got: $words", words.size > 1)
}
// addSpecialCharacters tests
@Test
fun addSpecialCharacters_addsExactlyThreeCharacters() {
val password = "hello world"
val result = PasswordGenerator.addSpecialCharacters(password)
assertEquals(password.length + 3, result.length)
}
@Test
fun addSpecialCharacters_containsAtLeastOneDigit() {
val password = "hello world"
val result = PasswordGenerator.addSpecialCharacters(password)
assertTrue("Expected a digit in '$result'", result.any { it.isDigit() })
}
@Test
fun addSpecialCharacters_containsAtLeastOneUppercaseLetter() {
val password = "hello world"
val result = PasswordGenerator.addSpecialCharacters(password)
assertTrue("Expected an uppercase letter in '$result'", result.any { it.isUpperCase() })
}
@Test
fun addSpecialCharacters_containsAtLeastOneSpecialCharacter() {
val specialChars = setOf('$', ',', '.', '#', '@', '!', '%', '&')
val password = "hello world"
val result = PasswordGenerator.addSpecialCharacters(password)
assertTrue("Expected a special character in '$result'", result.any { it in specialChars })
}
@Test
fun addSpecialCharacters_preservesOriginalCharsAsSubsequence() {
val password = "helloworld"
val result = PasswordGenerator.addSpecialCharacters(password)
var i = 0
result.forEach { c -> if (i < password.length && c == password[i]) i++ }
assertEquals("Original chars should remain as subsequence in '$result'", password.length, i)
}
@Test
fun addSpecialCharacters_withSameSeed_returnsSameResult() {
val password = "testpassword"
val result1 = PasswordGenerator.addSpecialCharacters(password, Random(99))
val result2 = PasswordGenerator.addSpecialCharacters(password, Random(99))
assertEquals(result1, result2)
}
}

View File

@ -1,133 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy Password Zebra</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 0 24px 60px;
color: #1a1a1a;
line-height: 1.7;
}
h1 { font-size: 1.8rem; margin-bottom: 0.25em; }
h2 { font-size: 1.1rem; margin-top: 2em; color: #333; }
.meta { color: #666; font-size: 0.9rem; margin-bottom: 2.5em; }
.lang-divider {
border: none;
border-top: 2px solid #e0e0e0;
margin: 3em 0;
}
@media (prefers-color-scheme: dark) {
body { background: #121212; color: #e0e0e0; }
h2 { color: #bbb; }
.meta { color: #888; }
.lang-divider { border-color: #333; }
}
</style>
</head>
<body>
<h1>Privacy Policy</h1>
<p class="meta">Password Zebra &nbsp;|&nbsp; Last updated: 2026-03-06</p>
<h2>Overview</h2>
<p>
Password Zebra is a fully <strong>offline</strong> application.
It does not connect to the internet, does not collect any personal data,
and does not transmit any information to external servers or third parties.
</p>
<h2>Data stored on your device</h2>
<p>The following data is stored <em>locally on your device only</em>,
encrypted via the Android Keystore system:</p>
<ul>
<li><strong>Device secret</strong> — a random value used together with your master password
to derive deterministic passwords. It never leaves the device unless you explicitly
export it via the QR backup feature.</li>
<li><strong>Service names and rotation counters</strong> — the list of services you have
used and any password rotation counts. Stored in encrypted SharedPreferences.</li>
</ul>
<p>Your master password is <strong>never stored</strong>. It is held in memory only
for the duration of password generation and immediately wiped afterwards.</p>
<h2>Camera</h2>
<p>Camera access is requested solely to scan QR codes for the import feature.
No images are saved, transmitted, or processed outside the app.</p>
<h2>Biometric authentication</h2>
<p>Biometric authentication (fingerprint, face unlock, device PIN/pattern) is handled
entirely by the Android operating system via the BiometricPrompt API.
The app receives only a success/failure result and has no access to any biometric data.</p>
<h2>Permissions</h2>
<ul>
<li><code>USE_BIOMETRIC</code> / <code>USE_FINGERPRINT</code> — biometric lock</li>
<li><code>CAMERA</code> — QR code scanning (import feature)</li>
</ul>
<p>No network, location, contacts, or storage permissions are used.</p>
<h2>Third-party libraries</h2>
<p>Password Zebra uses only open-source libraries that run locally and perform no
network activity: Bouncy Castle (cryptography), ZXing (QR codes), Jetpack / AndroidX.</p>
<h2>Contact</h2>
<p>Questions or concerns: open an issue at
<a href="https://git.bugsy.cz/beval/password_zebra/issues">git.bugsy.cz/beval/password_zebra/issues</a>.
</p>
<hr class="lang-divider">
<h1>Zásady ochrany soukromí</h1>
<p class="meta">Password Zebra &nbsp;|&nbsp; Poslední aktualizace: 2026-03-06</p>
<h2>Přehled</h2>
<p>
Password Zebra je zcela <strong>offline</strong> aplikace.
Nepřipojuje se k internetu, neshromažďuje žádné osobní údaje
a nepřenáší žádné informace na externí servery ani třetím stranám.
</p>
<h2>Data uložená ve vašem zařízení</h2>
<p>Následující data jsou uložena <em>výhradně ve vašem zařízení</em>,
šifrována prostřednictvím systému Android Keystore:</p>
<ul>
<li><strong>Device secret</strong> — náhodná hodnota používaná spolu s vaším hlavním heslem
k odvozování deterministických hesel. Zařízení nikdy neopustí, pokud jej explicitně
neexportujete prostřednictvím funkce QR zálohy.</li>
<li><strong>Názvy služeb a čítače rotací</strong> — seznam použitých služeb a případné
čítače rotace hesel. Uloženo v šifrovaných SharedPreferences.</li>
</ul>
<p>Vaše hlavní heslo <strong>není nikdy uloženo</strong>. Je drženo v paměti pouze
po dobu generování hesla a poté okamžitě vymazáno.</p>
<h2>Kamera</h2>
<p>Přístup ke kameře je vyžadován výhradně ke skenování QR kódů pro funkci importu.
Žádné snímky nejsou ukládány, přenášeny ani zpracovávány mimo aplikaci.</p>
<h2>Biometrické ověření</h2>
<p>Biometrické ověření (otisk prstu, odemčení obličejem, PIN/gesto) je plně zajišťováno
operačním systémem Android prostřednictvím rozhraní BiometricPrompt API.
Aplikace obdrží pouze výsledek úspěch/selhání a nemá přístup k žádným biometrickým datům.</p>
<h2>Oprávnění</h2>
<ul>
<li><code>USE_BIOMETRIC</code> / <code>USE_FINGERPRINT</code> — biometrický zámek</li>
<li><code>CAMERA</code> — skenování QR kódů (funkce importu)</li>
</ul>
<p>Nejsou použita žádná síťová, polohová, kontaktní ani úložišční oprávnění.</p>
<h2>Open-source knihovny</h2>
<p>Password Zebra používá pouze open-source knihovny fungující lokálně bez síťové aktivity:
Bouncy Castle (kryptografie), ZXing (QR kódy), Jetpack / AndroidX.</p>
<h2>Kontakt</h2>
<p>Dotazy nebo připomínky: otevřete issue na
<a href="https://git.bugsy.cz/beval/password_zebra/issues">git.bugsy.cz/beval/password_zebra/issues</a>.
</p>
</body>
</html>