Compare commits
No commits in common. "master" and "1.4.0" have entirely different histories.
13
.gitignore
vendored
13
.gitignore
vendored
@ -1,16 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
.claude
|
||||
CLAUDE.md
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
|
||||
15
.idea/deploymentTargetDropDown.xml
generated
15
.idea/deploymentTargetDropDown.xml
generated
@ -3,20 +3,7 @@
|
||||
<component name="deploymentTargetDropDown">
|
||||
<value>
|
||||
<entry key="app">
|
||||
<State>
|
||||
<targetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="QUICK_BOOT_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="VIRTUAL_DEVICE_PATH" />
|
||||
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_34_extension_level_7_x86_64.avd" />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2024-02-19T08:27:46.302081639Z" />
|
||||
</State>
|
||||
<State />
|
||||
</entry>
|
||||
</value>
|
||||
</component>
|
||||
|
||||
20
LICENSE
20
LICENSE
@ -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]
|
||||
68
README.md
68
README.md
@ -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 (1–10)
|
||||
- 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).
|
||||
@ -1,42 +1,25 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
val keystoreProps = Properties().also { props ->
|
||||
val f = rootProject.file("keystore.properties")
|
||||
if (f.exists()) props.load(f.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cz.bugsy.passwordzebra"
|
||||
compileSdk = 35
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cz.bugsy.passwordzebra"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 10
|
||||
versionName = "1.8.0"
|
||||
targetSdk = 34
|
||||
versionCode = 5
|
||||
versionName = "1.4.0"
|
||||
|
||||
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 {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@ -50,58 +33,15 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.10"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "META-INF/versions/9/OSGI-INF/**"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
|
||||
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")
|
||||
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(composeBom)
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="MediaStore.createWriteRequest" />
|
||||
<application
|
||||
android:name=".PasswordZebra"
|
||||
android:allowBackup="true"
|
||||
|
||||
@ -1,22 +1,147 @@
|
||||
package cz.bugsy.passwordzebra
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
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.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
|
||||
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
|
||||
import kotlin.random.Random
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(FLAG_SECURE)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
PasswordZebraTheme {
|
||||
AppNavigation()
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
generateButton = findViewById(R.id.generateButton)
|
||||
passwordTextView = findViewById(R.id.passwordTextView)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,8 @@ import com.google.android.material.color.DynamicColors
|
||||
|
||||
class PasswordZebra : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
// Apply dynamic color
|
||||
//DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
DynamicColors.applyToActivitiesIfAvailable(this, R.style.AppTheme_Overlay)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
BIN
app/src/main/res/drawable/icon_stift.png
Normal file
BIN
app/src/main/res/drawable/icon_stift.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
5
app/src/main/res/drawable/password_background.xml
Normal file
5
app/src/main/res/drawable/password_background.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
108
app/src/main/res/layout/activity_main.xml
Normal file
108
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,108 @@
|
||||
<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_marginStart="16dp"
|
||||
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="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"
|
||||
android:text=""
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/iconImageView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/passwordLengthLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="72dp"
|
||||
android:layout_marginBottom="96dp"
|
||||
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_constraintStart_toEndOf="@+id/passwordLengthLabel"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.953" />
|
||||
|
||||
<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: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="64dp"
|
||||
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>
|
||||
@ -1,80 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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="textview_password_length">Délka hesla</string>
|
||||
<string name="switch_without_spaces">Kopírovat bez mezer</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>
|
||||
@ -1,41 +1,5 @@
|
||||
|
||||
<resources>
|
||||
<style name="Theme.PasswordZebra" parent="Theme.Material3.Dark.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<!-- status bar font color -->
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<!-- true = status bar semi-transparent -->
|
||||
<item name="android:windowTranslucentStatus">false</item>
|
||||
|
||||
<!-- navigation bar = bottom bar with navigation buttons -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightNavigationBar">false</item>
|
||||
<item name="android:windowTranslucentNavigation">false</item>
|
||||
|
||||
<item name="colorPrimary">@color/md_theme_dark_primary</item>
|
||||
<item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
|
||||
<item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item>
|
||||
<item name="colorSecondary">@color/md_theme_dark_secondary</item>
|
||||
<item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item>
|
||||
<item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item>
|
||||
<item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item>
|
||||
<item name="colorTertiary">@color/md_theme_dark_tertiary</item>
|
||||
<item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item>
|
||||
<item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item>
|
||||
<item name="colorError">@color/md_theme_dark_error</item>
|
||||
<item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
|
||||
<item name="colorOnError">@color/md_theme_dark_onError</item>
|
||||
<item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
|
||||
<item name="android:colorBackground">@color/md_theme_dark_background</item>
|
||||
<item name="colorSurface">@color/md_theme_dark_surface</item>
|
||||
<item name="colorOnSurface">@color/md_theme_dark_onSurface</item>
|
||||
<item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item>
|
||||
<item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item>
|
||||
<item name="colorOutline">@color/md_theme_dark_outline</item>
|
||||
<item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
|
||||
<item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
|
||||
<item name="colorPrimaryInverse">@color/md_theme_dark_inversePrimary</item>
|
||||
<style name="Theme.PasswordZebra" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -11,67 +11,4 @@
|
||||
<color name="overlay_light_onPrimary">#FFFFFF</color>
|
||||
<color name="overlay_light_primaryContainer">#FFDADB</color>
|
||||
<color name="overlay_light_onPrimaryContainer">#400008</color>
|
||||
|
||||
<color name="seed">#1B5E20</color>
|
||||
<color name="md_theme_light_primary">#286C2A</color>
|
||||
<color name="md_theme_light_onPrimary">#FFFFFF</color>
|
||||
<color name="md_theme_light_primaryContainer">#ABF5A3</color>
|
||||
<color name="md_theme_light_onPrimaryContainer">#002203</color>
|
||||
<color name="md_theme_light_secondary">#00639A</color>
|
||||
<color name="md_theme_light_onSecondary">#FFFFFF</color>
|
||||
<color name="md_theme_light_secondaryContainer">#CEE5FF</color>
|
||||
<color name="md_theme_light_onSecondaryContainer">#001D32</color>
|
||||
<color name="md_theme_light_tertiary">#38656A</color>
|
||||
<color name="md_theme_light_onTertiary">#FFFFFF</color>
|
||||
<color name="md_theme_light_tertiaryContainer">#BCEBF0</color>
|
||||
<color name="md_theme_light_onTertiaryContainer">#002023</color>
|
||||
<color name="md_theme_light_error">#BA1A1A</color>
|
||||
<color name="md_theme_light_errorContainer">#FFDAD6</color>
|
||||
<color name="md_theme_light_onError">#FFFFFF</color>
|
||||
<color name="md_theme_light_onErrorContainer">#410002</color>
|
||||
<color name="md_theme_light_background">#FCFDF6</color>
|
||||
<color name="md_theme_light_onBackground">#1A1C19</color>
|
||||
<color name="md_theme_light_surface">#FCFDF6</color>
|
||||
<color name="md_theme_light_onSurface">#1A1C19</color>
|
||||
<color name="md_theme_light_surfaceVariant">#DEE5D8</color>
|
||||
<color name="md_theme_light_onSurfaceVariant">#424940</color>
|
||||
<color name="md_theme_light_outline">#72796F</color>
|
||||
<color name="md_theme_light_inverseOnSurface">#F1F1EB</color>
|
||||
<color name="md_theme_light_inverseSurface">#2F312D</color>
|
||||
<color name="md_theme_light_inversePrimary">#90D889</color>
|
||||
<color name="md_theme_light_shadow">#000000</color>
|
||||
<color name="md_theme_light_surfaceTint">#286C2A</color>
|
||||
<color name="md_theme_light_outlineVariant">#C2C9BD</color>
|
||||
<color name="md_theme_light_scrim">#000000</color>
|
||||
<color name="md_theme_dark_primary">#90D889</color>
|
||||
<color name="md_theme_dark_onPrimary">#003909</color>
|
||||
<color name="md_theme_dark_primaryContainer">#085314</color>
|
||||
<color name="md_theme_dark_onPrimaryContainer">#ABF5A3</color>
|
||||
<color name="md_theme_dark_secondary">#96CCFF</color>
|
||||
<color name="md_theme_dark_onSecondary">#003353</color>
|
||||
<color name="md_theme_dark_secondaryContainer">#004A75</color>
|
||||
<color name="md_theme_dark_onSecondaryContainer">#CEE5FF</color>
|
||||
<color name="md_theme_dark_tertiary">#A0CFD4</color>
|
||||
<color name="md_theme_dark_onTertiary">#00363B</color>
|
||||
<color name="md_theme_dark_tertiaryContainer">#1E4D52</color>
|
||||
<color name="md_theme_dark_onTertiaryContainer">#BCEBF0</color>
|
||||
<color name="md_theme_dark_error">#FFB4AB</color>
|
||||
<color name="md_theme_dark_errorContainer">#93000A</color>
|
||||
<color name="md_theme_dark_onError">#690005</color>
|
||||
<color name="md_theme_dark_onErrorContainer">#FFDAD6</color>
|
||||
<color name="md_theme_dark_background">#1A1C19</color>
|
||||
<color name="md_theme_dark_onBackground">#E2E3DD</color>
|
||||
<color name="md_theme_dark_surface">#1A1C19</color>
|
||||
<color name="md_theme_dark_onSurface">#E2E3DD</color>
|
||||
<color name="md_theme_dark_surfaceVariant">#424940</color>
|
||||
<color name="md_theme_dark_onSurfaceVariant">#C2C9BD</color>
|
||||
<color name="md_theme_dark_outline">#8C9388</color>
|
||||
<color name="md_theme_dark_inverseOnSurface">#1A1C19</color>
|
||||
<color name="md_theme_dark_inverseSurface">#E2E3DD</color>
|
||||
<color name="md_theme_dark_inversePrimary">#286C2A</color>
|
||||
<color name="md_theme_dark_shadow">#000000</color>
|
||||
<color name="md_theme_dark_surfaceTint">#90D889</color>
|
||||
<color name="md_theme_dark_outlineVariant">#424940</color>
|
||||
<color name="md_theme_dark_scrim">#000000</color>
|
||||
|
||||
</resources>
|
||||
|
||||
@ -1,79 +1,8 @@
|
||||
<resources>
|
||||
<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="textview_password_length">Password Length</string>
|
||||
<string name="switch_without_spaces">Copy Without Spaces</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>
|
||||
@ -1,18 +1,5 @@
|
||||
|
||||
<resources>
|
||||
<style name="Theme.PasswordZebra" parent="Theme.Material3.Light.NoActionBar">
|
||||
<!-- statusbar background color -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
|
||||
<!-- false = white statusbar text / true = black -->
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
|
||||
<!-- true = status bar semi-transparent -->
|
||||
<item name="android:windowTranslucentStatus">false</item>
|
||||
|
||||
<!-- navigation bar = bottom bar with navigation buttons -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightNavigationBar">true</item>
|
||||
<item name="android:windowTranslucentNavigation">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<style name="AppTheme.Overlay" parent="ThemeOverlay.Material3.DynamicColors.DayNight" />
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes_overlays.xml.xml
Normal file
5
app/src/main/res/values/themes_overlays.xml.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="AppTheme.Overlay" parent="ThemeOverlay.Material3.DynamicColors.DayNight">
|
||||
<item name="iconColor">@android:color/system_accent2_600</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 | 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 | 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>
|
||||
Loading…
x
Reference in New Issue
Block a user