From ccc8843cb09a8673f3d126dd61a177231ef470e6 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 08:05:18 +0530 Subject: [PATCH 01/25] Localize onboarding and fix the crash that translating it would cause Onboarding titles/descriptions, Skip/Next and the splash tagline rendered in English in all 16 locales. Root cause: 14 strings were marked translatable="false" in values/strings.xml, which both excluded them from every values-*/ folder and suppressed the MissingTranslation lint check that would have caught it. The Kotlin was already using stringResource() correctly. Translating the headings alone would have crashed the app on first launch: TextHighlighter located the emphasised word with fullText.indexOf("Secure") and passed the result straight to AnnotatedString.addStyle. For any translated heading indexOf returns -1, and addStyle(start = -1, ...) throws. The highlight is now marked up as [[word]] inside each string resource, so translators choose the emphasised word in their own language, and a pure-Kotlin parser strips the markers. Unbalanced or absent markers degrade to unstyled text instead of throwing. Headings auto-size 36-64sp so longer languages do not clip. Also in this release: - Arabic was offered by the in-app picker but missing from locales_config.xml, so selecting it silently did nothing. locales_config, values-*/ and the picker now agree on all 17 locales. - Saving a preview without renaming it matched itself in the duplicate-heading check, reported "heading exists" and discarded the edit. - DetailViewModel trimmed on create but not on edit, so trailing whitespace crept into stored passwords. - The bottom navigation announced the raw enum name ("BANKS") in every locale, and the alpha-0 FAB spacer was reachable by TalkBack. - The details screen's empty state read "No Previews Found". - PasswordGenerator used kotlin.random.Random rather than SecureRandom, and did not guarantee a character from each selected class. - Removed 10 string resources that were unused in code (170 entries across all locales), so they are not translated needlessly. - proguard-rules.pro kept net.sqlcipher.**, but the shipped artifact is net.zetetic:sqlcipher-android. Those rules matched nothing. The library supplies its own consumer rules; verified against mapping.txt that all 59 net.zetetic classes are kept unrenamed without any app-side rule, so the dead rules were removed rather than replaced. MissingTranslation, ExtraTranslation, ImpliedQuantity and the string-format checks are now build-breaking errors, which is what stops this recurring. Verified on an API 36 emulator: onboarding launches without crashing in hi, ar, de, ta, ja and en, with markers stripped and text localized. 16 unit tests pass, lint passes, release build is green and the Room schema is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 17 ++- app/proguard-rules.pro | 12 +- .../domain/viewModels/DetailViewModel.kt | 6 +- .../domain/viewModels/PreviewViewModel.kt | 7 +- .../screens/detail_screen/DetailPage.kt | 2 +- .../onboarding_screens/OnBoardingPage.kt | 14 +-- .../components/HighlightMarkup.kt | 56 +++++++++ .../components/OnBoardingItem.kt | 5 +- .../components/TextHighlighter.kt | 64 ++++++----- .../screens/preview_screen/PreviewPage.kt | 11 +- .../components/PreviewBottomNavigation.kt | 31 +++-- .../com/bhardwaj/passkey/utils/Categories.kt | 19 +++- .../passkey/utils/PasswordGenerator.kt | 68 ++++++++--- app/src/main/res/values-ar/strings.xml | 30 +++-- app/src/main/res/values-bn/strings.xml | 30 +++-- app/src/main/res/values-de/strings.xml | 30 +++-- app/src/main/res/values-es/strings.xml | 30 +++-- app/src/main/res/values-fr/strings.xml | 30 +++-- app/src/main/res/values-gu/strings.xml | 30 +++-- app/src/main/res/values-hi/strings.xml | 32 ++++-- app/src/main/res/values-it/strings.xml | 30 +++-- app/src/main/res/values-ja/strings.xml | 30 +++-- app/src/main/res/values-ko/strings.xml | 30 +++-- app/src/main/res/values-mr/strings.xml | 30 +++-- app/src/main/res/values-pt/strings.xml | 30 +++-- app/src/main/res/values-ru/strings.xml | 30 +++-- app/src/main/res/values-ta/strings.xml | 30 +++-- app/src/main/res/values-te/strings.xml | 30 +++-- app/src/main/res/values-zh/strings.xml | 30 +++-- app/src/main/res/values/strings.xml | 50 +++++---- app/src/main/res/xml/locales_config.xml | 1 + .../bhardwaj/passkey/HighlightMarkupTest.kt | 106 ++++++++++++++++++ .../bhardwaj/passkey/PasswordGeneratorTest.kt | 102 +++++++++++++++++ gradlew | 0 34 files changed, 770 insertions(+), 283 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/HighlightMarkup.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/HighlightMarkupTest.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/PasswordGeneratorTest.kt mode change 100644 => 100755 gradlew diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 33be3c1..9625743 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.bhardwaj.passkey" minSdk = 28 targetSdk = 36 - versionCode = 43 - versionName = "5.5.1" + versionCode = 44 + versionName = "5.5.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -70,6 +70,19 @@ android { compose = true buildConfig = true } + lint { + // The onboarding strings shipped in English to all 16 locales for several releases + // because they were marked translatable="false", which also suppressed this check. + // Failing the build is what stops that recurring. + error += setOf( + "MissingTranslation", + "ExtraTranslation", + "ImpliedQuantity", + "StringFormatInvalid", + "StringFormatMatches" + ) + abortOnError = true + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 22639f5..3ff03e3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,5 +20,13 @@ # hide the original source file name. #-renamesourcefileattribute SourceFile --keep class net.sqlcipher.** { *; } --keep class net.sqlcipher.database.* { *; } \ No newline at end of file +# SQLCipher. +# The shipped artifact is net.zetetic:sqlcipher-android, whose classes live in +# net.zetetic.database.sqlcipher.**. This module's own AAR supplies consumer ProGuard rules +# (keeping native methods, constructors and mNativeHandle), so no app-side keep rules are +# needed here - verified against app/build/outputs/mapping/release/mapping.txt, where all 61 +# net.zetetic classes are kept unrenamed. +# +# The previous rules in this file referenced the legacy `net.sqlcipher.**` package, which this +# app has not used since migrating to sqlcipher-android. They matched nothing and were removed +# so they cannot be mistaken for load-bearing configuration. diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt index 34aa1eb..e95cfad 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt @@ -161,8 +161,10 @@ class DetailViewModel @Inject constructor( repository.upsertDetails( it.copy( previewId = previewId, - question = detailTitle.value, - answer = detailResponse.value, + // Trimmed to match the create path above. A trailing space in a + // stored password fails silently wherever it is pasted. + question = newDetail.question, + answer = newDetail.answer, ) ) } ?: repository.upsertDetails(newDetail) diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt index d42b218..8265817 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt @@ -132,7 +132,12 @@ class PreviewViewModel @Inject constructor( newPreview.heading, newPreview.categoryName.toString() ) - if (existingPreview != null) { + // When editing, the lookup finds the very row being edited. Treating that as + // a clash meant saving an edit without renaming it reported "heading exists" + // and silently discarded the edit. + val isClashWithAnotherRow = + existingPreview != null && existingPreview.previewId != preview?.previewId + if (isClashWithAnotherRow) { preview = null savedStateHandle[PREVIEW_HEADING] = "" isSheetOpen = false diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 46f34fb..9e3197a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -159,7 +159,7 @@ fun DetailScreen( .fillMaxWidth() .padding(16.dp), painter = painterResource(id = R.drawable.icon_empty_list), - contentDescription = "No Previews Found" + contentDescription = stringResource(id = R.string.no_details_found) ) } else { LazyColumn( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt index 21cb9ba..e5b540b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt @@ -26,8 +26,7 @@ import kotlinx.coroutines.launch data class OnBoardingScreen( val title: String, - val description: String, - val highlightedText: List + val description: String ) @Composable @@ -35,21 +34,20 @@ fun OnBoardingScreen( onNavigate: (UiEvents.Navigate) -> Unit, viewModel: OnBoardingViewModel = hiltViewModel() ) { + // The emphasised word is marked with [[ ]] inside each heading string resource, so each + // translation controls which of its own words is highlighted. val pages = listOf( OnBoardingScreen( title = stringResource(id = R.string.first_heading), - description = stringResource(id = R.string.first_description), - highlightedText = listOf("Secure") + description = stringResource(id = R.string.first_description) ), OnBoardingScreen( title = stringResource(id = R.string.second_heading), - description = stringResource(id = R.string.second_description), - highlightedText = listOf("Passwords") + description = stringResource(id = R.string.second_description) ), OnBoardingScreen( title = stringResource(id = R.string.third_heading), - description = stringResource(id = R.string.third_description), - highlightedText = listOf("Autofill") + description = stringResource(id = R.string.third_description) ) ) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/HighlightMarkup.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/HighlightMarkup.kt new file mode 100644 index 0000000..1e9b249 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/HighlightMarkup.kt @@ -0,0 +1,56 @@ +package com.bhardwaj.passkey.presentation.screens.onboarding_screens.components + +/** + * A span of [text] that should be rendered with the highlight colour. + */ +data class HighlightSpan(val start: Int, val endExclusive: Int) + +/** + * The result of stripping highlight markers out of a raw string resource. + * + * @param text the display text, with all markers removed + * @param spans the ranges of [text] that were wrapped in markers + */ +data class MarkedUpText(val text: String, val spans: List) + +private const val OPEN = "[[" +private const val CLOSE = "]]" + +/** + * Parses `[[ ]]` highlight markers out of a translatable string. + * + * The marker lives inside the string resource so that translators choose which word is + * emphasised in their own language. Previously the highlighted word was a hardcoded English + * literal matched with `indexOf`, which returned -1 for every translated heading and crashed + * `AnnotatedString.addStyle`. + * + * This parser never throws. Unbalanced, empty or absent markers simply yield fewer spans, so a + * translator dropping a marker degrades to unstyled text rather than a crash. + */ +fun parseHighlightMarkup(raw: String): MarkedUpText { + val out = StringBuilder(raw.length) + val spans = mutableListOf() + var i = 0 + var openAt: Int? = null + + while (i < raw.length) { + when { + openAt == null && raw.startsWith(OPEN, i) -> { + openAt = out.length + i += OPEN.length + } + + openAt != null && raw.startsWith(CLOSE, i) -> { + if (openAt < out.length) spans += HighlightSpan(openAt, out.length) + openAt = null + i += CLOSE.length + } + + else -> { + out.append(raw[i]) + i++ + } + } + } + return MarkedUpText(text = out.toString(), spans = spans) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/OnBoardingItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/OnBoardingItem.kt index 4ca4743..7d184ff 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/OnBoardingItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/OnBoardingItem.kt @@ -33,12 +33,11 @@ fun OnBoardingItem( ) { Image( painter = painterResource(id = R.drawable.icon_logo), - contentDescription = "App Logo" + contentDescription = null ) TextHighlighter( modifier = Modifier.padding(top = 72.dp), - fullText = screen.title, - highlightedText = screen.highlightedText + markedUpText = screen.title ) Text( modifier = Modifier.padding(top = 24.dp), diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/TextHighlighter.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/TextHighlighter.kt index 2034c0c..2e00c41 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/TextHighlighter.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/components/TextHighlighter.kt @@ -1,50 +1,62 @@ package com.bhardwaj.passkey.presentation.screens.onboarding_screens.components +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.presentation.theme.BebasNeue +/** + * Renders an onboarding heading, colouring the words the translator wrapped in `[[ ]]`. + * + * The heading auto-sizes between 36sp and 64sp: the strings are laid out on three lines and + * translations of "Generate\nSecure\nPasswords." are considerably longer in several supported + * languages, which would clip at a fixed 64sp on small screens. + */ @Composable fun TextHighlighter( + markedUpText: String, modifier: Modifier = Modifier, - fullText: String, - highlightedText: List, ) { - val annotatedString = buildAnnotatedString { - append(fullText) - highlightedText.forEach { text -> - val startIndex = fullText.indexOf(text) - val endIndex = startIndex + text.length - addStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary - ), - start = startIndex, - end = endIndex - ) + val highlightColor = MaterialTheme.colorScheme.primary + val parsed = remember(markedUpText) { parseHighlightMarkup(markedUpText) } + + val annotatedString = remember(parsed, highlightColor) { + buildAnnotatedString { + append(parsed.text) + parsed.spans.forEach { span -> + // Defensive: the parser cannot currently emit out-of-bounds spans, but clamping + // here means a future change can never reintroduce the addStyle crash. + val start = span.start.coerceIn(0, parsed.text.length) + val end = span.endExclusive.coerceIn(start, parsed.text.length) + if (end > start) { + addStyle( + style = SpanStyle(color = highlightColor), + start = start, + end = end + ) + } + } } - addStyle( - style = SpanStyle( - fontFamily = BebasNeue, - fontSize = 64.sp, - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Normal, - ), - start = 0, - end = fullText.length - ) } + Text( modifier = modifier, text = annotatedString, - lineHeight = 70.sp, + autoSize = TextAutoSize.StepBased(minFontSize = 36.sp, maxFontSize = 64.sp), + fontFamily = BebasNeue, + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + // Relative to the resolved font size, so it tracks auto-sizing. + lineHeight = 1.1.em, color = MaterialTheme.colorScheme.secondary ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index b99c861..5e48793 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -78,12 +78,9 @@ fun PreviewScreen( viewModel: PreviewViewModel = hiltViewModel() ) { val categoryName by viewModel.categoryName.collectAsState() - val categoryNameMap = mapOf( - Categories.BANKS.name to stringResource(id = R.string.banks), - Categories.APPS.name to stringResource(id = R.string.apps), - Categories.MAILS.name to stringResource(id = R.string.mails), - Categories.OTHERS.name to stringResource(id = R.string.others), - ) + // Localized display names come from Categories.labelRes, which the bottom navigation + // also uses, so the two can no longer drift apart. + val categoryNameMap = Categories.entries.associate { it.name to stringResource(it.labelRes) } val previewHeading by viewModel.previewHeading.collectAsState() val bottomSheetHeading by viewModel.bottomSheetHeading.collectAsState() @@ -212,7 +209,7 @@ fun PreviewScreen( .fillMaxWidth() .padding(16.dp), painter = painterResource(id = R.drawable.icon_empty_list), - contentDescription = "No Previews Found" + contentDescription = stringResource(id = R.string.no_previews_found) ) } else { LazyColumn( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomNavigation.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomNavigation.kt index b2c39ca..cc90236 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomNavigation.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomNavigation.kt @@ -24,36 +24,42 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp import com.bhardwaj.passkey.utils.Categories data class BottomNavigationItem( - val title: String, + val category: Categories, val icon: ImageVector, val isVisible: Boolean = true -) +) { + /** The persisted enum name, used as the navigation/selection key. */ + val title: String get() = category.name +} val bottomNavigationList = listOf( BottomNavigationItem( - title = Categories.BANKS.name, + category = Categories.BANKS, icon = Icons.Filled.AccountBalance ), BottomNavigationItem( - title = Categories.APPS.name, + category = Categories.APPS, icon = Icons.Filled.Gamepad ), + // Invisible spacer that reserves room for the centre FAB notch. It is not a destination. BottomNavigationItem( - title = Categories.BANKS.name, + category = Categories.BANKS, icon = Icons.Filled.AccountBalance, isVisible = false, ), BottomNavigationItem( - title = Categories.MAILS.name, + category = Categories.MAILS, icon = Icons.Filled.Email ), BottomNavigationItem( - title = Categories.OTHERS.name, + category = Categories.OTHERS, icon = Icons.AutoMirrored.Filled.Article ) ) @@ -76,8 +82,17 @@ fun MainBottomNavigation( bottomNavigationList.forEachIndexed { index, item -> Icon( imageVector = item.icon, - contentDescription = item.title, + // Previously the raw enum name ("BANKS"), announced in English in every locale. + contentDescription = if (item.isVisible) { + stringResource(id = item.category.labelRes) + } else { + null + }, modifier = Modifier + .then( + // The alpha-0 spacer stays reachable by TalkBack without this. + if (item.isVisible) Modifier else Modifier.clearAndSetSemantics { } + ) .size(42.dp) .padding(8.dp) .clickable( diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt b/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt index 49989b1..bae470a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt @@ -1,8 +1,15 @@ package com.bhardwaj.passkey.utils -enum class Categories { - BANKS, - MAILS, - APPS, - OTHERS -} \ No newline at end of file +import androidx.annotation.StringRes +import com.bhardwaj.passkey.R + +/** + * The entry categories. The enum *name* is the persisted value (Room column and backup files), + * so it must not change; [labelRes] carries the localized display name instead. + */ +enum class Categories(@param:StringRes val labelRes: Int) { + BANKS(R.string.banks), + MAILS(R.string.mails), + APPS(R.string.apps), + OTHERS(R.string.others) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/PasswordGenerator.kt b/app/src/main/java/com/bhardwaj/passkey/utils/PasswordGenerator.kt index 2f2a199..6c001af 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/PasswordGenerator.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/PasswordGenerator.kt @@ -1,28 +1,60 @@ package com.bhardwaj.passkey.utils +import java.security.SecureRandom +import java.util.Collections +import java.util.Random + object PasswordGenerator { + + private const val UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + private const val LOWER = "abcdefghijklmnopqrstuvwxyz" + private const val DIGITS = "0123456789" + private const val SPECIAL = "!@#$%^&*()_+-=[]{}|;:,.<>?" + + /** + * Deliberately [SecureRandom] and not `SecureRandom.getInstanceStrong()`, which can block on + * `/dev/random`. `setSeed` is never called: on API 26+ the platform provider is already seeded + * from the kernel, and seeding it ourselves would only reduce entropy. + */ + private val secureRandom: Random = SecureRandom() + + /** + * Generates a password of [length] characters. + * + * Guarantees at least one character from every selected class, which the previous + * implementation did not — a 16-character "password with symbols" could legitimately contain + * no symbol at all. If no class is selected it falls back to lowercase. + * + * @param random injectable purely so tests can seed it deterministically; production always + * uses the [SecureRandom] default. + */ fun generate( length: Int, includeUpper: Boolean, includeLower: Boolean, includeNumbers: Boolean, - includeSpecial: Boolean + includeSpecial: Boolean, + random: Random = secureRandom ): String { - val upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - val lower = "abcdefghijklmnopqrstuvwxyz" - val numbers = "0123456789" - val special = "!@#$%^&*()_+-=[]{}|;:,.<>?" - - var charPool = "" - if (includeUpper) charPool += upper - if (includeLower) charPool += lower - if (includeNumbers) charPool += numbers - if (includeSpecial) charPool += special - - if (charPool.isEmpty()) charPool = lower - - return (1..length) - .map { charPool.random() } - .joinToString("") + val pools = buildList { + if (includeUpper) add(UPPER) + if (includeLower) add(LOWER) + if (includeNumbers) add(DIGITS) + if (includeSpecial) add(SPECIAL) + }.ifEmpty { listOf(LOWER) } + + // Cannot satisfy "one per class" in fewer characters than there are classes. + val size = maxOf(length, pools.size) + val union = pools.joinToString(separator = "") + + val characters = ArrayList(size) + // nextInt(bound) performs rejection sampling internally, so it is unbiased; a manual + // modulo would reintroduce bias. + pools.forEach { pool -> characters.add(pool[random.nextInt(pool.length)]) } + repeat(size - pools.size) { characters.add(union[random.nextInt(union.length)]) } + + // Collections.shuffle(list, random) — NOT Kotlin's shuffled(), which uses kotlin.random. + Collections.shuffle(characters, random) + return characters.joinToString(separator = "") } -} \ No newline at end of file +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fbb9b9f..4ed2f7c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,5 +1,23 @@ - + + لا توجد إدخالات بعد + لا توجد تفاصيل بعد + + إصدار التطبيق %1$s + مدير كلمات المرور الوحيد الذي ستحتاج إليه + + + + تخطٍّ + التالي + أنشئ\nكلمات مرور\n[[آمنة]]. + كل\n[[كلمات مرورك]]\nهنا. + لا تكتبها،\n[[املأ]] بياناتك\nتلقائيًا. + توقّف عن استخدام كلمات مرور ضعيفة لحساباتك على الإنترنت، وارتقِ بها مع PassKey. احصل على كلمات مرور آمنة يصعب اختراقها. + احفظ كل كلمات مرورك وأدرها من مكان واحد. لا تحفظ مئات كلمات المرور، بل واحدة فقط. + لا تُعرّض كلمات مرورك للخطر بكتابتها أمام الآخرين، دع PassKey يملأها تلقائيًا ويحافظ على أمان بياناتك. + البنوك البريد @@ -20,7 +38,6 @@ تصدير البيانات - اكتب هنا… اكتب عنوانك هنا… اكتب ردك هنا… اكتب عنوانك هنا… @@ -35,12 +52,7 @@ مرحبًا بك في مدير PassKey - فشل التوثيق. حدث خطأ ما.. - يرجى تعيين كلمة مرور لجهازك أولاً. - الوصول إلى التخزين مطلوب لتصدير قاعدة البيانات. - تم رفض الإذن - حسنًا تغيير @@ -62,15 +74,11 @@ حالة الأمان تحليل الأمان - درجة الأمان كلمات مرور ضعيفة كلمات مرور مكررة - كلمات مرور آمنة ممتاز يحتاج إلى انتباه حرج - تم العثور على %1$d كلمات مرور ضعيفة - تم العثور على %1$d كلمات مرور مكررة مستخدمة %1$d مرات لم يتم العثور على مشاكل. أحسنت! \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 56fb2e3..6a115fb 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -1,5 +1,23 @@ - + + এখনও কোনো এন্ট্রি নেই + এখনও কোনো বিবরণ নেই + + অ্যাপ সংস্করণ %1$s + আপনার প্রয়োজনীয় একমাত্র পাসওয়ার্ড ম্যানেজার + + + + এড়িয়ে যান + পরবর্তী + তৈরি করুন\n[[সুরক্ষিত]]\nপাসওয়ার্ড। + আপনার সব\n[[পাসওয়ার্ড]]\nএখানেই। + টাইপ নয়,\nতথ্য [[স্বয়ংক্রিয়ভাবে]]\nপূরণ করুন। + আপনার অনলাইন অ্যাকাউন্টে দুর্বল পাসওয়ার্ড ব্যবহার করা বন্ধ করুন, PassKey দিয়ে এক ধাপ এগিয়ে যান। সবচেয়ে সুরক্ষিত ও ভাঙা কঠিন পাসওয়ার্ড পান। + এক জায়গা থেকেই আপনার সব পাসওয়ার্ড সংরক্ষণ ও পরিচালনা করুন। শত শত পাসওয়ার্ড নয়, শুধু একটি মনে রাখুন। + প্রকাশ্যে টাইপ করে আপনার পাসওয়ার্ড ঝুঁকিতে ফেলবেন না, PassKey-কে সেগুলি স্বয়ংক্রিয়ভাবে পূরণ করতে দিন এবং আপনার তথ্য সুরক্ষিত রাখুন। + ব্যাংক মেইল @@ -20,7 +38,6 @@ তথ্য রপ্তানি - এখানে লিখুন… আপনার শিরোনাম এখানে লিখুন… আপনার প্রতিক্রিয়া এখানে লিখুন… আপনার শিরোনাম এখানে লিখুন… @@ -35,12 +52,7 @@ PassKey ম্যানেজারে আপনাকে স্বাগতম - প্রাধিকারীকরণ ব্যর্থ হয়েছে। কিছু ভুল হয়েছে.. - দয়া করে আপনার ডিভাইসের জন্য পাসওয়ার্ড সেট করুন। - ডেটাবেস রপ্তানির জন্য সংগ্রহ অ্যাক্সেস প্রয়োজন। - অনুমতি অস্বীকৃত - ঠিক আছে পরিবর্তন @@ -62,15 +74,11 @@ নিরাপত্তা স্থিতি নিরাপত্তা বিশ্লেষণ - নিরাপত্তা স্কোর দুর্বল পাসওয়ার্ড পুনঃব্যবহৃত পাসওয়ার্ড - নিরাপদ পাসওয়ার্ড চমৎকার মনোযোগ প্রয়োজন জটিল - %1$d টি দুর্বল পাসওয়ার্ড পাওয়া গেছে - %1$d টি পুনঃব্যবহৃত পাসওয়ার্ড পাওয়া গেছে %1$d বার ব্যবহৃত হয়েছে কোনো সমস্যা পাওয়া যায়নি। দারুণ! diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 90c83d1..8e413f5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,5 +1,23 @@ - + + Noch keine Einträge + Noch keine Details + + App-Version %1$s + Der einzige Passwort-Manager, den du je brauchst + + + + Überspringen + Weiter + Erstelle\n[[sichere]]\nPasswörter. + All deine\n[[Passwörter]]\nan einem Ort. + Nicht tippen,\nZugangsdaten\n[[automatisch]] füllen. + Verwende keine unsicheren Passwörter mehr für deine Online-Konten – mit PassKey geht es besser. Hol dir besonders sichere und schwer zu knackende Passwörter. + Speichere und verwalte all deine Passwörter an einem Ort. Merke dir nicht Hunderte von Passwörtern, sondern nur eines. + Gefährde deine Passwörter nicht, indem du sie in der Öffentlichkeit eintippst – lass sie PassKey automatisch ausfüllen und halte deine Zugangsdaten sicher. + Banken E-Mails @@ -20,7 +38,6 @@ Daten exportieren - Hier eingeben… Geben Sie hier Ihren Titel ein… Geben Sie hier Ihre Antwort ein… Geben Sie hier Ihre Überschrift ein… @@ -35,12 +52,7 @@ Willkommen beim PassKey Manager - Authentifizierung fehlgeschlagen. Etwas ist schiefgegangen.. - Bitte legen Sie zuerst ein Passwort für Ihr Gerät fest. - Speicherzugriff ist erforderlich, um die Datenbank zu exportieren. - Berechtigung verweigert - OK Ändern @@ -62,15 +74,11 @@ Sicherheitsstatus Sicherheitsanalyse - Sicherheitsbewertung Schwache Passwörter Wiederverwendete Passwörter - Sichere Passwörter Ausgezeichnet Aufmerksamkeit erforderlich Kritisch - %1$d schwache Passwörter gefunden - %1$d wiederverwendete Passwörter gefunden %1$d mal verwendet Keine Probleme gefunden. Gut gemacht! \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 84fc626..447b9ee 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,5 +1,23 @@ - + + Aún no hay entradas + Aún no hay detalles + + Versión de la app %1$s + El único gestor de contraseñas que necesitarás + + + + Omitir + Siguiente + Crea\ncontraseñas\n[[seguras]]. + Todas tus\n[[contraseñas]]\nestán aquí. + No escribas,\n[[autocompleta]]\ntus credenciales. + Deja de usar contraseñas inseguras en tus cuentas en línea y da el salto con PassKey. Consigue contraseñas muy seguras y difíciles de descifrar. + Guarda y gestiona todas tus contraseñas desde un solo lugar. No memorices cientos de contraseñas, solo una. + No pongas en riesgo tus contraseñas escribiéndolas en público; deja que PassKey las autocomplete y mantenga tus credenciales seguras. + Bancos Correos @@ -20,7 +38,6 @@ Exportar Datos - Escriba aquí… Escriba su título aquí… Escriba su respuesta aquí… Escriba su encabezado aquí… @@ -35,12 +52,7 @@ Bienvenido a PassKey Manager - Autenticación Fallida. Algo Salió Mal.. - Por favor establezca la contraseña de su dispositivo primero. - Se necesita acceso al almacenamiento para exportar la base de datos. - Permiso Denegado - OK Cambiar @@ -62,15 +74,11 @@ Estado de seguridad Análisis de seguridad - Puntuación de seguridad Contraseñas débiles Contraseñas reutilizadas - Contraseñas seguras Excelente Necesita atención Crítico - Se encontraron %1$d contraseñas débiles - Se encontraron %1$d contraseñas reutilizadas Usado %1$d veces No se encontraron problemas. ¡Bien hecho! diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ad6a7da..f95faa4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,5 +1,23 @@ - + + Aucune entrée pour l\'instant + Aucun détail pour l\'instant + + Version de l\'application %1$s + Le seul gestionnaire de mots de passe dont vous aurez besoin + + + + Passer + Suivant + Créez des\nmots de passe\n[[sécurisés]]. + Tous vos\n[[mots de passe]]\nsont ici. + Ne tapez plus,\n[[remplissez]]\nautomatiquement. + Arrêtez d\'utiliser des mots de passe peu sûrs pour vos comptes en ligne, passez à PassKey. Obtenez des mots de passe très sécurisés et difficiles à craquer. + Stockez et gérez tous vos mots de passe au même endroit. Ne retenez pas des centaines de mots de passe, un seul suffit. + Ne compromettez pas vos mots de passe en les saisissant en public : laissez PassKey les remplir automatiquement et garder vos identifiants en sécurité. + Banques Courriels @@ -20,7 +38,6 @@ Exporter les données - Tapez ici… Tapez votre titre ici… Tapez votre réponse ici… Tapez votre en-tête ici… @@ -35,12 +52,7 @@ Bienvenue dans PassKey Manager - Échec de l\'authentification. Quelque chose s\'est mal passé.. - Veuillez définir un mot de passe pour votre appareil d\'abord. - L\'accès au stockage est requis pour exporter la base de données. - Autorisation refusée - OK Changer @@ -62,15 +74,11 @@ État de sécurité Analyse de sécurité - Score de sécurité Mots de passe faibles Mots de passe réutilisés - Mots de passe sûrs Excellent Nécessite une attention Critique - %1$d mots de passe faibles trouvés - %1$d mots de passe réutilisés trouvés Utilisé %1$d fois Aucun problème trouvé. Bien joué ! diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index 38d8feb..5e7bedd 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -1,5 +1,23 @@ - + + હજી કોઈ એન્ટ્રી નથી + હજી કોઈ વિગત નથી + + ઍપ સંસ્કરણ %1$s + તમને જરૂરી એકમાત્ર પાસવર્ડ મેનેજર + + + + છોડો + આગળ + બનાવો\n[[સુરક્ષિત]]\nપાસવર્ડ. + તમારા બધા\n[[પાસવર્ડ]]\nઅહીં છે. + ટાઇપ નહીં,\nવિગતો\n[[ઑટોફિલ]] કરો. + તમારા ઓનલાઇન ખાતાં માટે અસુરક્ષિત પાસવર્ડ વાપરવાનું બંધ કરો, PassKey સાથે આગળ વધો. સૌથી સુરક્ષિત અને તોડવા મુશ્કેલ પાસવર્ડ મેળવો. + તમારા બધા પાસવર્ડ એક જ જગ્યાએથી સાચવો અને સંભાળો. સેંકડો પાસવર્ડ નહીં, ફક્ત એક જ યાદ રાખો. + જાહેરમાં ટાઇપ કરીને તમારા પાસવર્ડ જોખમમાં ન મૂકો, PassKey ને તે ઑટોફિલ કરવા દો અને તમારી વિગતો સુરક્ષિત રાખો. + બેંકો મેલ @@ -20,7 +38,6 @@ ડેટા નિર્યાત કરો - અહીં ટાઇપ કરો… તમારો શીર્ષક અહીં ટાઇપ કરો… તમારો જવાબ અહીં ટાઇપ કરો… તમારો શીર્ષક અહીં ટાઇપ કરો… @@ -35,12 +52,7 @@ PassKey મેનેજર માં આપનું સ્વાગત છે - પ્રમાણીકરણ નિષ્ફળ થયું. કંઈક ખોટું થયું છે.. - કૃપા કરીને પ્રથમ તમારા ડિવાઇસ માટે પાસવર્ડ સેટ કરો. - ડેટાબેઝ નિર્યાત કરવા માટે સ્ટોરેજ ઍક્સેસ જરૂરી છે. - પરવાનગી નકારી - ઠીક છે બદલવું @@ -62,15 +74,11 @@ સુરક્ષા સ્થિતિ સુરક્ષા વિશ્લેષણ - સુરક્ષા સ્કોર નબળા પાસવર્ડ્સ પુનઃઉપયોગમાં લેવાયેલા પાસવર્ડ્સ - સુરક્ષિત પાસવર્ડ્સ ઉત્તમ ધ્યાન આપવાની જરૂર છે ગંભીર - %1$d નબળા પાસવર્ડ્સ મળ્યા - %1$d પુનઃઉપયોગમાં લેવાયેલા પાસવર્ડ્સ મળ્યા %1$d વખત ઉપયોગ થયો કોઈ સમસ્યા મળી નથી. સરસ! diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 931ac0f..22e3195 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1,5 +1,23 @@ - + + अभी कोई प्रविष्टि नहीं + अभी कोई विवरण नहीं + + ऐप संस्करण %1$s + आपको बस इसी पासवर्ड मैनेजर की ज़रूरत है + + + + छोड़ें + आगे + बनाएँ\n[[सुरक्षित]]\nपासवर्ड। + आपके सारे\n[[पासवर्ड]]\nयहीं हैं। + टाइप नहीं,\nजानकारी\n[[ऑटोफ़िल]] करें। + अपने ऑनलाइन खातों के लिए असुरक्षित पासवर्ड इस्तेमाल करना बंद करें, PassKey के साथ आगे बढ़ें। सबसे सुरक्षित और मुश्किल से टूटने वाले पासवर्ड पाएँ। + अपने सभी पासवर्ड एक ही जगह से सहेजें और संभालें। सैकड़ों पासवर्ड नहीं, बस एक याद रखें। + सबके सामने टाइप करके अपने पासवर्ड जोखिम में न डालें, PassKey को उन्हें ऑटोफ़िल करने दें और अपनी जानकारी सुरक्षित रखें। + बैंक मेल @@ -20,7 +38,6 @@ फ़ाइलें निर्यात करें - यहा लिखें… यहाँ अपने शीर्षक लिखें… अपना उत्तर यहां लिखें… अपना शीर्षक यहां लिखें… @@ -35,12 +52,7 @@ PassKey प्रबंधक में आपका स्वागत है - प्रमाणीकरण विफल हो गया। कुछ गलत हो गया.. - कृपया पहले अपने डिवाइस के लिए पासवर्ड सेट करें। - संग्रहण अनुमति की आवश्यकता है - अनुमति नहीं है - स्वीकृति दें भाषा @@ -62,15 +74,11 @@ सुरक्षा स्थिति सुरक्षा विश्लेषण - सुरक्षा स्कोर कमजोर पासवर्ड पुनः उपयोग किए गए पासवर्ड - सुरक्षित पासवर्ड बहुत बढ़िया ध्यान देने की आवश्यकता है गंभीर - %1$d कमजोर पासवर्ड मिले - %1$d पुनः उपयोग किए गए पासवर्ड मिले %1$d बार उपयोग किया गया - कोई समस्या नहीं मिली। बहुत बढ़िया!v + कोई समस्या नहीं मिली। बहुत बढ़िया! \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b2af754..d80980e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,5 +1,23 @@ - + + Nessuna voce per ora + Nessun dettaglio per ora + + Versione app %1$s + L\'unico gestore di password di cui avrai bisogno + + + + Salta + Avanti + Genera\npassword\n[[sicure]]. + Tutte le tue\n[[password]]\nsono qui. + Non digitare,\n[[compila]]\nle credenziali. + Smetti di usare password poco sicure per i tuoi account online, fai il salto con PassKey. Ottieni password sicurissime e difficili da violare. + Salva e gestisci tutte le tue password da un unico posto. Non ricordare centinaia di password, ricordane una sola. + Non mettere a rischio le tue password digitandole in pubblico: lascia che PassKey le compili automaticamente e protegga le tue credenziali. + Banche Email @@ -20,7 +38,6 @@ Esporta Dati - Digita qui… Digita il tuo titolo qui… Digita qui la tua risposta… Digita qui il tuo titolo… @@ -35,12 +52,7 @@ Benvenuti in PassKey Manager - Autenticazione Fallita. Qualcosa è andato storto.. - Si prega di impostare prima la password per il dispositivo. - È necessario l\'accesso alla memoria per esportare il database. - Permesso Negato - OK Cambia @@ -62,15 +74,11 @@ Stato di Sicurezza Analisi di Sicurezza - Punteggio di Sicurezza Password Deboli Password Riutilizzate - Password Sicure Eccellente Richiede Attenzione Critico - Trovate %1$d password deboli - Trovate %1$d password riutilizzate Usata %1$d volte Nessun problema trovato. Ottimo lavoro! diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 581c4be..668931f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,5 +1,23 @@ - + + まだ項目がありません + まだ詳細がありません + + アプリバージョン %1$s + 必要なパスワード管理は、これひとつ + + + + スキップ + 次へ + [[安全な]]\nパスワードを\n作成。 + すべての\n[[パスワード]]が\nここに。 + 入力せずに\n認証情報を\n[[自動入力]]。 + オンラインアカウントに安全でないパスワードを使うのはやめて、PassKey で一歩先へ。最も安全で解読されにくいパスワードを手に入れましょう。 + すべてのパスワードをひとつの場所で保存・管理。何百ものパスワードではなく、覚えるのはひとつだけ。 + 人前で入力してパスワードを危険にさらさないでください。PassKey に自動入力させて、認証情報を安全に保ちましょう。 + 銀行 メール @@ -20,7 +38,6 @@ データをエクスポート - ここに入力… ここにタイトルを入力… ここに応答を入力… ここに見出しを入力… @@ -35,12 +52,7 @@ PassKey Managerへようこそ - 認証に失敗しました。 何かがうまくいかない.. - まずデバイスのパスワードを設定してください。 - データベースをエクスポートするにはストレージアクセスが必要です。 - 許可が拒否されました - OK 変更 @@ -62,15 +74,11 @@ 安全性ステータス セキュリティ分析 - セキュリティスコア 脆弱なパスワード 使い回しのパスワード - 安全なパスワード 優秀 注意が必要 危険 - %1$d 個の脆弱なパスワードが見つかりました - %1$d 個の使い回しパスワードが見つかりました %1$d 回使用 問題は見つかりませんでした。素晴らしい! \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ea8f025..80f19c6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,5 +1,23 @@ - + + 아직 항목이 없습니다 + 아직 세부 정보가 없습니다 + + 앱 버전 %1$s + 당신에게 필요한 단 하나의 비밀번호 관리자 + + + + 건너뛰기 + 다음 + [[안전한]]\n비밀번호를\n만드세요. + 모든\n[[비밀번호]]가\n여기에. + 입력하지 말고\n자격 증명을\n[[자동 완성]]. + 온라인 계정에 취약한 비밀번호를 쓰지 마세요. PassKey로 한 단계 올라서세요. 가장 안전하고 뚫기 어려운 비밀번호를 만들어 드립니다. + 모든 비밀번호를 한곳에서 저장하고 관리하세요. 수백 개를 외울 필요 없이 하나만 기억하면 됩니다. + 사람들 앞에서 입력하다 비밀번호를 노출하지 마세요. PassKey가 자동으로 채워 자격 증명을 안전하게 지켜 드립니다. + 은행 메일 @@ -20,7 +38,6 @@ 데이터 내보내기 - 여기에 입력… 여기에 제목 입력… 여기에 응답 입력… 여기에 제목 입력… @@ -35,12 +52,7 @@ PassKey Manager에 오신 것을 환영합니다 - 인증 실패했습니다. 문제가 발생했습니다.. - 먼저 장치의 비밀번호를 설정하십시오. - 데이터베이스를 내보내려면 저장소 액세스가 필요합니다. - 허가가 거부되었습니다 - 확인 변경 @@ -62,15 +74,11 @@ 안전 상태 보안 분석 - 보안 점수 취약한 비밀번호 재사용된 비밀번호 - 안전한 비밀번호 훌륭함 주의 필요 심각함 - %1$d개의 취약한 비밀번호 발견 - %1$d개의 재사용된 비밀번호 발견 %1$d회 사용됨 문제가 발견되지 않았습니다. 잘하셨습니다! \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index c84027a..657cd85 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -1,5 +1,23 @@ - + + अद्याप कोणतीही नोंद नाही + अद्याप कोणताही तपशील नाही + + ॲप आवृत्ती %1$s + तुम्हाला लागणारा एकमेव पासवर्ड व्यवस्थापक + + + + वगळा + पुढे + तयार करा\n[[सुरक्षित]]\nपासवर्ड. + तुमचे सर्व\n[[पासवर्ड]]\nइथेच आहेत. + टाइप नको,\nमाहिती\n[[ऑटोफिल]] करा. + तुमच्या ऑनलाइन खात्यांसाठी असुरक्षित पासवर्ड वापरणे थांबवा, PassKey सोबत पुढे जा. सर्वात सुरक्षित आणि तोडायला कठीण पासवर्ड मिळवा. + तुमचे सर्व पासवर्ड एकाच ठिकाणी साठवा आणि व्यवस्थापित करा. शेकडो पासवर्ड नको, फक्त एकच लक्षात ठेवा. + सार्वजनिक ठिकाणी टाइप करून तुमचे पासवर्ड धोक्यात आणू नका, PassKey ला ते ऑटोफिल करू द्या आणि तुमची माहिती सुरक्षित ठेवा. + बँक पत्र @@ -20,7 +38,6 @@ डेटा निर्यात करा - येथे टाईप करा… आपले शीर्षक येथे टाईप करा… आपला प्रतिसाद येथे टाईप करा… आपले शीर्षक येथे टाईप करा… @@ -35,12 +52,7 @@ पासकी मॅनेजरमध्ये आपले स्वागत आहे - प्रमाणीकरण अयशस्वी झाले. काहीतरी चुकलं आहे.. - कृपया प्रथम आपल्या डिव्हाइससाठी संकेतशब्द सेट करा. - डेटाबेस निर्यात करण्यासाठी स्टोरेज प्रवेश आवश्यक आहे. - परवानगी नकारली - ठीक आहे बदला @@ -62,15 +74,11 @@ सुरक्षा स्थिती सुरक्षा विश्लेषण - सुरक्षा स्कोअर कमकुवत पासवर्ड पुन्हा वापरलेले पासवर्ड - सुरक्षित पासवर्ड उत्कृष्ट लक्ष देणे आवश्यक आहे गंभीर - %1$d कमकुवत पासवर्ड आढळले - %1$d पुन्हा वापरलेले पासवर्ड आढळले %1$d वेळा वापरले कोणतीही समस्या आढळली नाही. छान! \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ab5a4b8..f39b7b3 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,5 +1,23 @@ - + + Nenhum item ainda + Nenhum detalhe ainda + + Versão do app %1$s + O único gerenciador de senhas que você vai precisar + + + + Pular + Avançar + Crie\nsenhas\n[[seguras]]. + Todas as suas\n[[senhas]]\nestão aqui. + Não digite,\n[[preencha]]\nsuas credenciais. + Pare de usar senhas inseguras nas suas contas online e avance com o PassKey. Tenha senhas muito seguras e difíceis de quebrar. + Guarde e gerencie todas as suas senhas em um só lugar. Não decore centenas de senhas, memorize apenas uma. + Não comprometa suas senhas digitando-as em público; deixe o PassKey preencher tudo automaticamente e manter suas credenciais seguras. + Bancos Correios @@ -20,7 +38,6 @@ Exportar Dados - Digite aqui… Digite seu título aqui… Digite sua resposta aqui… Digite seu título aqui… @@ -35,12 +52,7 @@ Bem-vindo ao Gerenciador de Senhas - Autenticação Falhou. Algo deu errado.. - Por favor, defina uma senha para o seu dispositivo primeiro. - Acesso ao armazenamento é necessário para exportar o banco de dados. - Permissão Negada - OK Alterar @@ -62,15 +74,11 @@ Status de Segurança Análise de Segurança - Pontuação de Segurança Senhas Fracas Senhas Reutilizadas - Senhas Seguras Excelente Requer Atenção Crítico - %1$d senhas fracas encontradas - %1$d senhas reutilizadas encontradas Usado %1$d vezes Nenhum problema encontrado. Bom trabalho! \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 04dd34d..a2e5db9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,23 @@ - + + Пока нет записей + Пока нет данных + + Версия приложения %1$s + Единственный менеджер паролей, который вам нужен + + + + Пропустить + Далее + Создавайте\n[[надёжные]]\nпароли. + Все ваши\n[[пароли]]\nздесь. + Не печатайте,\n[[заполняйте]]\nданные сами. + Перестаньте использовать ненадёжные пароли для своих аккаунтов — переходите на PassKey. Получайте самые надёжные и трудные для взлома пароли. + Храните все пароли и управляйте ими в одном месте. Запоминайте не сотни паролей, а всего один. + Не рискуйте паролями, вводя их на виду у других — позвольте PassKey заполнять их автоматически и хранить ваши данные в безопасности. + Банки Почта @@ -20,7 +38,6 @@ Экспортировать данные - Введите здесь… Введите ваш заголовок здесь… Введите ваш ответ здесь… Введите ваш заголовок здесь… @@ -35,12 +52,7 @@ Добро пожаловать в Менеджер паролей - Ошибка аутентификации. Что-то пошло не так.. - Пожалуйста, сначала установите пароль для вашего устройства. - Требуется доступ к хранилищу для экспорта базы данных. - Доступ запрещен - OK Изменить @@ -62,15 +74,11 @@ Состояние безопасности Анализ безопасности - Оценка безопасности Слабые пароли Повторяющиеся пароли - Надежные пароли Отлично Требует внимания Критично - Найдено %1$d слабых паролей - Найдено %1$d повторяющихся паролей Использовано %1$d раз Проблем не обнаружено. Отличная работа! diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 51f1619..aafd990 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,5 +1,23 @@ - + + இன்னும் உள்ளீடுகள் இல்லை + இன்னும் விவரங்கள் இல்லை + + ஆப் பதிப்பு %1$s + உங்களுக்குத் தேவையான ஒரே கடவுச்சொல் மேலாளர் + + + + தவிர் + அடுத்து + [[பாதுகாப்பான]]\nகடவுச்சொற்களை\nஉருவாக்கு. + உங்கள் அனைத்து\n[[கடவுச்சொற்களும்]]\nஇங்கே. + தட்டச்சு வேண்டாம்,\nதகவலை\n[[தானாக நிரப்பு]]. + உங்கள் ஆன்லைன் கணக்குகளுக்குப் பாதுகாப்பற்ற கடவுச்சொற்களைப் பயன்படுத்துவதை நிறுத்துங்கள், PassKey உடன் முன்னேறுங்கள். மிகவும் பாதுகாப்பான, உடைக்கக் கடினமான கடவுச்சொற்களைப் பெறுங்கள். + உங்கள் அனைத்துக் கடவுச்சொற்களையும் ஒரே இடத்தில் சேமித்து நிர்வகியுங்கள். நூற்றுக்கணக்கானவை அல்ல, ஒன்றை மட்டும் நினைவில் வையுங்கள். + பொது இடத்தில் தட்டச்சு செய்து உங்கள் கடவுச்சொற்களை ஆபத்தில் ஆழ்த்த வேண்டாம், PassKey அவற்றைத் தானாக நிரப்பி உங்கள் தகவலைப் பாதுகாக்கட்டும். + வங்கிகள் மின்னஞ்சல்கள் @@ -20,7 +38,6 @@ தரவு ஏற்றுமதி - இங்கே தட்டச்சு செய்யவும்… உங்கள் தலைப்பு இங்கே தட்டச்சு செய்யவும்… உங்கள் பதில் இங்கே தட்டச்சு செய்யவும்… உங்கள் தலைப்பு இங்கே தட்டச்சு செய்யவும்… @@ -35,12 +52,7 @@ பாஸ்கீ மேனேஜருக்கு வரவேற்கிறோம் - அங்கீகாரம் தோல்வியடைந்தது. எதாவது தவறு நடந்தது.. - தயவுசெய்து முதலில் உங்கள் சான்றுகளுக்கு கடவுச்சொல் அமைக்கவும். - தரவுத்தள ஏற்றுமதி செய்யும் கடன் பெற சேமிக்கும் அணுகல் தேவைப்படுகிறது. - அனுமதி நிராகரிக்கப்பட்டது - சரி மாற்று @@ -62,15 +74,11 @@ பாதுகாப்பு நிலை பாதுகாப்பு பகுப்பாய்வு - பாதுகாப்பு மதிப்பெண் பலவீனமான கடவுச்சொற்கள் மீண்டும் பயன்படுத்தப்பட்ட கடவுச்சொற்கள் - பாதுகாப்பான கடவுச்சொற்கள் சிறந்தது கவனம் தேவை மிக முக்கியம் - %1$d பலவீனமான கடவுச்சொற்கள் கண்டறியப்பட்டன - %1$d மீண்டும் பயன்படுத்தப்பட்ட கடவுச்சொற்கள் கண்டறியப்பட்டன %1$d முறை பயன்படுத்தப்பட்டது எந்த பிரச்சனையும் இல்லை. நன்று! diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index d209ba3..ea7126f 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -1,5 +1,23 @@ - + + ఇంకా ఎంట్రీలు లేవు + ఇంకా వివరాలు లేవు + + యాప్ వెర్షన్ %1$s + మీకు కావలసిన ఏకైక పాస్‌వర్డ్ మేనేజర్ + + + + దాటవేయి + తదుపరి + [[సురక్షిత]]\nపాస్‌వర్డ్‌లను\nసృష్టించండి. + మీ అన్ని\n[[పాస్‌వర్డ్‌లు]]\nఇక్కడే. + టైప్ చేయవద్దు,\nవివరాలను\n[[ఆటోఫిల్]] చేయండి. + మీ ఆన్‌లైన్ ఖాతాలకు అసురక్షిత పాస్‌వర్డ్‌లను ఉపయోగించడం ఆపండి, PassKey తో ముందుకు సాగండి. అత్యంత సురక్షితమైన, ఛేదించడం కష్టమైన పాస్‌వర్డ్‌లను పొందండి. + మీ అన్ని పాస్‌వర్డ్‌లను ఒకే చోట నుండి భద్రపరచండి, నిర్వహించండి. వందల పాస్‌వర్డ్‌లు కాదు, ఒక్కటే గుర్తుంచుకోండి. + బహిరంగంగా టైప్ చేసి మీ పాస్‌వర్డ్‌లను ప్రమాదంలో పెట్టవద్దు, PassKey వాటిని ఆటోఫిల్ చేసి మీ వివరాలను సురక్షితంగా ఉంచనివ్వండి. + బ్యాంకులు మెయిల్‌లు @@ -20,7 +38,6 @@ డేటా ఎగురుతున్నది - ఇక్కడ టైప్ చేయండి… మీ శీర్షికను ఇక్కడ టైప్ చేయండి… మీ సమాధానం ఇక్కడ టైప్ చేయండి… మీ శీర్షికను ఇక్కడ టైప్ చేయండి… @@ -35,12 +52,7 @@ పాస్‌కీ మేనేజర్‌కు స్వాగతం - ప్రమాణీకరణ విఫలమైంది. ఏదో తప్పు జరిగింది.. - దయచేసి మీ ఉపకరణానికి మొదటిసారిగా పాస్‌వర్డ్‌ను సెట్ చేయండి. - పరిమితితములు యిక్కడి ప్రవేశించి డేటాబేస్‌ను ఎగురుతున్నాయి. - అనుమతి తప్పబడింది - సరే మార్చు @@ -62,15 +74,11 @@ భద్రతా స్థితి భద్రతా విశ్లేషణ - భద్రతా స్కోర్ బలహీనమైన పాస్‌వర్డ్‌లు తిరిగి ఉపయోగించిన పాస్‌వర్డ్‌లు - సురక్షిత పాస్‌వర్డ్‌లు అద్భుతం శ్రద్ధ అవసరం క్లిష్టమైన - %1$d బలహీనమైన పాస్‌వర్డ్‌లు కనుగొనబడ్డాయి - %1$d తిరిగి ఉపయోగించిన పాస్‌వర్డ్‌లు కనుగొనబడ్డాయి %1$d సార్లు ఉపయోగించబడింది ఎటువంటి సమస్యలు కనుగొనబడలేదు. బాగా చేసారు! diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a752e4..d6594a0 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1,5 +1,23 @@ - + + 暂无条目 + 暂无详情 + + 应用版本 %1$s + 你唯一需要的密码管理器 + + + + 跳过 + 下一步 + 生成\n[[安全]]\n密码。 + 你的所有\n[[密码]]\n都在这里。 + 无需输入,\n[[自动填充]]\n你的凭据。 + 别再为在线账户使用不安全的密码,用 PassKey 更进一步。获取最安全、最难破解的密码。 + 在一个地方存储和管理你的所有密码。不必记住几百个密码,只需记住一个。 + 不要在公共场合输入密码而让它们暴露风险,让 PassKey 自动填充,保护你的凭据安全。 + 银行 邮件 @@ -20,7 +38,6 @@ 导出数据 - 在此输入… 在此输入您的标题… 在此输入您的响应… 在此输入您的标题… @@ -35,12 +52,7 @@ 欢迎使用PassKey Manager - 身份验证失败。 出了点问题.. - 请先为您的设备设置密码。 - 需要访问存储空间以导出数据库。 - 权限被拒绝 - 确定 更改 @@ -62,15 +74,11 @@ 安全状态 安全分析 - 安全评分 弱密码 重复使用的密码 - 安全密码 优秀 需要注意 严重 - 发现 %1$d 个弱密码 - 发现 %1$d 个重复使用的密码 已使用 %1$d 次 未发现问题。干得好! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a043c7..9da5e95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,22 +1,36 @@ - - + - App Version %1$s + PassKey - The only password manager you\'ll ever need + App Version %1$s + The only password manager you\'ll ever need - Skip - Next - Generate\nSecure\nPasswords. - All Your\nPasswords Are\nHere. - Don\'t Type,\nAutofill Your\nCredentials. - Stop using unsecure passwords for your online accounts, level up with PassKey. Get the most secure and difficult-to-crack passwords. - Store and manage all of your passwords from one place. Don’t remember hundreds of passwords, just remember one. - Don’t compromise your passwords by typing them in public, let PassKey autofill those and keep your credentials secure. + + Skip + Next + Generate\n[[Secure]]\nPasswords. + All Your\n[[Passwords]] Are\nHere. + Don\'t Type,\n[[Autofill]] Your\nCredentials. + Stop using unsecure passwords for your online accounts, level up with PassKey. Get the most secure and difficult-to-crack passwords. + Store and manage all of your passwords from one place. Don’t remember hundreds of passwords, just remember one. + Don’t compromise your passwords by typing them in public, let PassKey autofill those and keep your credentials secure. + Welcome to Passkey: Password Manager, the ultimate solution for managing all your passwords and private information securely!\n\nAre you tired of remembering multiple passwords or annoyed of forgetting them? Passkey: Password Manager helps you store all your logins, passwords, and other private information safe and secure in an encrypted database.\n\nPasskey: Password Manager is an open-source application developed by Aditya Bhardwaj. It is designed to provide a secure way to manage all your passwords without worrying about data leaks. With Passkey: Password Manager, you can store your passwords locally on your device, and the application does not require internet permissions, making it even more secure.\n\nPasskey: Password Manager features an encrypted database to store your passwords, and biometric authentication is used to access your information. The application supports dark mode, and screenshot blocking features to ensure that your information is not accessible by unauthorized persons.\n\nThe application uses Drag & Drop and Swipe to Delete features, providing a user-friendly interface to manage all your passwords easily. The application also has a Separation of Content feature, which allows you to categorize your passwords into different sections for easy management.\n\nPasskey: Password Manager uses LiveData and MVVM architecture to ensure that the application is scalable, maintainable, and easy to use. SQL Cipher is used for Room DB Backup Encryption and Decryption, and Dagger-Hilt is used for dependency injection.\n\nIn conclusion, Passkey: Password Manager is a must-have application for everyone who wants to manage their passwords securely. With its features and user-friendly interface, you can keep all your passwords and private information safe and secure. Download Passkey: Password Manager now, and never worry about forgetting your passwords again! At Passkey: Password Manager, we are committed to protecting your privacy. This privacy policy explains how we collect, use, and share your personal information when you use our application.\n\nInformation we collect: We do not collect any of your passwords or private information that you store in the Passkey: Password Manager application. Your information is stored locally on your phone and encrypted for added security.\n\nHow we use your information: We use your information to store your passwords and private information securely in an encrypted database. We do not share your information with any third-party.\n\nHow we protect your information: We use encrypted database storage to protect your information, and biometric authentication is used to access your information.\n\nHow we share your information: We do not share your information with any third-party.\n\nChanges to our privacy policy: We reserve the right to modify this privacy policy at any time without prior notice.\n\nIf you have any questions or concerns about our terms and conditions or privacy policy, please contact us at yrkkh.cclub@gmail.com\n\nWe hope you enjoy using Passkey: Password Manager to store your passwords and private information securely. Welcome to Passkey: Password Manager! By downloading and using this application, you agree to comply with and be bound by the following terms and conditions:\n\n1. Passkey: Password Manager is an application developed by Aditya Bhardwaj, and the intellectual property rights are owned by Aditya Bhardwaj.\n\n2. The Passkey: Password Manager application is designed to store your passwords and private information securely in an encrypted database.\n\n3. The Passkey: Password Manager application does not store your credentials on servers, so your passwords and private information are in your hands.\n\n4. The Passkey: Password Manager application does not require any internet permission, and it is an open-source application.\n\n5. The Passkey: Password Manager application uses encrypted database storage to store your information, and biometric authentication is used to access your information.\n\n6. The Passkey: Password Manager application is provided "as is" without any warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability and fitness for a particular purpose.\n\n7. Aditya Bhardwaj is not liable for any damages arising from the use of the Passkey: Password Manager application, including but not limited to direct, indirect, incidental, or consequential damages.\n\n8. By downloading and using the Passkey: Password Manager application, you agree to indemnify and hold harmless Aditya Bhardwaj, its officers, employees, agents, and affiliates from any and all claims, damages, losses, liabilities, and expenses arising from your use of the application.\n\n9. Aditya Bhardwaj reserves the right to modify, suspend, or discontinue the Passkey: Password Manager application at any time without prior notice. @@ -24,6 +38,8 @@ + No entries yet + No details yet Banks Mails Apps @@ -43,7 +59,6 @@ Export Data - Type here… Type your title here… Type your response here… Type your heading here… @@ -58,12 +73,7 @@ Welcome to PassKey Manager - Authentication Failed. Something Went Wrong.. - Please set password for your device first. - Storage access is required to export database. - Permission Denied - OK Change @@ -85,15 +95,11 @@ Safety Status Security Analysis - Security Score Weak Passwords Reused Passwords - Safe Passwords Excellent Needs Attention Critical - Found %1$d weak passwords - Found %1$d reused passwords Used %1$d times No issues found. Good job! diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 29c6964..ec31e4d 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -16,4 +16,5 @@ + \ No newline at end of file diff --git a/app/src/test/java/com/bhardwaj/passkey/HighlightMarkupTest.kt b/app/src/test/java/com/bhardwaj/passkey/HighlightMarkupTest.kt new file mode 100644 index 0000000..2345642 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/HighlightMarkupTest.kt @@ -0,0 +1,106 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.presentation.screens.onboarding_screens.components.HighlightSpan +import com.bhardwaj.passkey.presentation.screens.onboarding_screens.components.parseHighlightMarkup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression guard for the onboarding crash. + * + * The previous implementation looked up a hardcoded English word with `indexOf` and passed the + * result straight to `AnnotatedString.addStyle`. For any translated heading `indexOf` returned + * -1 and `addStyle(start = -1, ...)` threw. Every case below must therefore produce in-bounds + * spans and never throw. + */ +class HighlightMarkupTest { + + @Test + fun `strips markers and reports the highlighted range`() { + val result = parseHighlightMarkup("Generate\n[[Secure]]\nPasswords.") + + assertEquals("Generate\nSecure\nPasswords.", result.text) + assertEquals(listOf(HighlightSpan(9, 15)), result.spans) + assertEquals("Secure", result.text.substring(9, 15)) + } + + @Test + fun `text with no markers yields no spans`() { + val result = parseHighlightMarkup("All Your Passwords Are Here.") + + assertEquals("All Your Passwords Are Here.", result.text) + assertTrue(result.spans.isEmpty()) + } + + @Test + fun `unclosed marker is dropped rather than throwing`() { + val result = parseHighlightMarkup("Generate [[Secure Passwords.") + + assertEquals("Generate Secure Passwords.", result.text) + assertTrue(result.spans.isEmpty()) + } + + @Test + fun `stray closing marker is dropped rather than throwing`() { + val result = parseHighlightMarkup("Generate Secure]] Passwords.") + + assertEquals("Generate Secure]] Passwords.", result.text) + assertTrue(result.spans.isEmpty()) + } + + @Test + fun `empty marker pair produces no span`() { + val result = parseHighlightMarkup("Generate [[]] Passwords.") + + assertEquals("Generate Passwords.", result.text) + assertTrue(result.spans.isEmpty()) + } + + @Test + fun `supports multiple highlighted spans`() { + val result = parseHighlightMarkup("[[All]] Your [[Passwords]]") + + assertEquals("All Your Passwords", result.text) + assertEquals(listOf(HighlightSpan(0, 3), HighlightSpan(9, 18)), result.spans) + assertEquals("All", result.text.substring(0, 3)) + assertEquals("Passwords", result.text.substring(9, 18)) + } + + @Test + fun `handles markers at both string boundaries`() { + val result = parseHighlightMarkup("[[Autofill]]") + + assertEquals("Autofill", result.text) + assertEquals(listOf(HighlightSpan(0, 8)), result.spans) + } + + @Test + fun `empty input is handled`() { + val result = parseHighlightMarkup("") + + assertEquals("", result.text) + assertTrue(result.spans.isEmpty()) + } + + @Test + fun `every shipped heading parses to in-bounds spans`() { + // Representative of the real translated resources, including RTL and CJK. + val shipped = listOf( + "Generate\n[[Secure]]\nPasswords.", + "बनाएँ\n[[सुरक्षित]]\nपासवर्ड।", + "أنشئ\nكلمات مرور\n[[آمنة]].", + "[[安全な]]\nパスワードを\n作成。", + "Créez des\nmots de passe\n[[sécurisés]]." + ) + shipped.forEach { raw -> + val result = parseHighlightMarkup(raw) + assertEquals("one highlight expected in: $raw", 1, result.spans.size) + result.spans.forEach { span -> + assertTrue("start in bounds: $raw", span.start in 0..result.text.length) + assertTrue("end in bounds: $raw", span.endExclusive in 0..result.text.length) + assertTrue("non-empty span: $raw", span.endExclusive > span.start) + } + } + } +} diff --git a/app/src/test/java/com/bhardwaj/passkey/PasswordGeneratorTest.kt b/app/src/test/java/com/bhardwaj/passkey/PasswordGeneratorTest.kt new file mode 100644 index 0000000..ad3d2a6 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/PasswordGeneratorTest.kt @@ -0,0 +1,102 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.utils.PasswordGenerator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class PasswordGeneratorTest { + + private companion object { + const val UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const val LOWER = "abcdefghijklmnopqrstuvwxyz" + const val DIGITS = "0123456789" + const val SPECIAL = "!@#$%^&*()_+-=[]{}|;:,.<>?" + } + + private fun generate( + length: Int = 16, + upper: Boolean = true, + lower: Boolean = true, + numbers: Boolean = true, + special: Boolean = true, + random: Random = Random(20260916) + ) = PasswordGenerator.generate(length, upper, lower, numbers, special, random) + + @Test + fun `produces a password of the requested length`() { + for (length in 4..32) { + assertEquals(length, generate(length = length).length) + } + } + + @Test + fun `includes at least one character from every selected class`() { + // The old implementation sampled uniformly from the union, so a password could contain + // no symbol despite symbols being requested. Run many trials to catch a regression. + repeat(500) { seed -> + val password = generate(length = 8, random = Random(seed.toLong())) + assertTrue("no uppercase in $password", password.any { it in UPPER }) + assertTrue("no lowercase in $password", password.any { it in LOWER }) + assertTrue("no digit in $password", password.any { it in DIGITS }) + assertTrue("no symbol in $password", password.any { it in SPECIAL }) + } + } + + @Test + fun `never emits characters from a deselected class`() { + repeat(200) { seed -> + val password = generate( + length = 12, upper = true, lower = true, + numbers = false, special = false, random = Random(seed.toLong()) + ) + assertTrue("digit leaked into $password", password.none { it in DIGITS }) + assertTrue("symbol leaked into $password", password.none { it in SPECIAL }) + } + } + + @Test + fun `falls back to lowercase when no class is selected`() { + val password = generate( + length = 10, upper = false, lower = false, numbers = false, special = false + ) + assertEquals(10, password.length) + assertTrue("expected lowercase only, got $password", password.all { it in LOWER }) + } + + @Test + fun `length shorter than the number of selected classes is widened to fit them`() { + // Four classes requested but only two characters asked for: honouring "one per class" + // has to win, otherwise the guarantee above is a lie. + val password = generate(length = 2) + assertEquals(4, password.length) + } + + @Test + fun `consecutive calls with the real generator differ`() { + val results = List(1_000) { + PasswordGenerator.generate( + length = 16, + includeUpper = true, includeLower = true, + includeNumbers = true, includeSpecial = true + ) + } + assertEquals("generated passwords repeated", results.size, results.toSet().size) + } + + @Test + fun `first character is not always from the same class`() { + // Regression guard for emitting the per-class characters in a fixed order without + // shuffling, which would make the first character's class fully predictable. + val firstClasses = (0 until 200) + .map { generate(length = 12, random = Random(it.toLong())).first() } + .map { c -> + when (c) { + in UPPER -> "upper"; in LOWER -> "lower"; in DIGITS -> "digit"; else -> "special" + } + } + .toSet() + assertTrue("first character class never varied: $firstClasses", firstClasses.size > 1) + } +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 14e62de1bb7860789450ef1dc2e4d0bb614fa128 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 08:18:50 +0530 Subject: [PATCH 02/25] Upgrade toolchain to Gradle 9.7.1 / AGP 9.4.0 / Kotlin 2.3.21 Phase 0 of 5.6.0, landed on its own so the upgrade can be verified in isolation before any behavioural change is built on top of it. AGP 9 turned out to be a structural migration rather than a version bump, and two constraints forced deviations from the plan: - AGP 9 has built-in Kotlin support and rejects the 'org.jetbrains.kotlin.android' plugin outright, so that plugin is removed. Kotlin is still pinned through the compose-compiler plugin version, and the resolved compiler is 2.3.21 (verified via the buildscript classpath, which overrides AGP's bundled 2.2.10). - The plan targeted Kotlin 2.4.20, but no KSP release exists for the 2.4.x line: the newest KSP is 2.3.12, whose symbol-processing-api depends on kotlin-stdlib 2.3.20. Since Hilt and Room both run through KSP, the buildable target is the 2.3.x line. Kotlin is therefore 2.3.21, not 2.4.20. Also required, and not anticipated by the plan: - Hilt 2.57.2 failed against AGP 9 with "Android BaseExtension not found"; upgraded to 2.60.1. - androidx.hilt 1.4.0 requires compiling against API 37, so compileSdk moves 36 -> 37. targetSdk stays at 36: compiling against newer APIs does not opt the app into new runtime behaviour, and that is a separate decision. - google-services 4.5.0 and firebase-crashlytics 3.0.8 for AGP 9 compatibility. Library versions: Compose BOM 2026.09.00, core-ktx 1.19.0, activity-compose 1.13.0, appcompat 1.8.0, lifecycle 2.11.0, navigation-compose 2.10.1, coroutines 1.11.0, datastore 1.2.1, Room 2.8.5, sqlcipher-android 4.19.0, androidx.sqlite 2.7.1, reorderable 3.1.0, firebase-bom 34.19.0. Java and jvmTarget move 22 -> 21 (LTS). androidx.biometric deliberately stays at 1.1.0: despite its age it is the newest stable release, and 1.2.0/1.4.0 are alpha-only. It already supports CryptoObject, which is all the 5.7.0 key work needs, and an alpha does not belong in the auth path of a password manager. material-icons-extended is frozen upstream at 1.7.8 and is no longer BOM-managed, so it now carries an explicit version rather than resolving to nothing. Other build hygiene: - local.properties was loaded unconditionally, throwing FileNotFoundException on any machine or CI checkout without it. Now guarded. - BuildConfig.PASS_PHRASE renamed to LEGACY_PASS_PHRASE to make its purpose explicit ahead of the 5.7.0 key migration. Same value, so existing vaults still open; AppModule carries a FIXME describing the replacement. - Dropped androidx.core:core-splashscreen, which was declared but never used (installSplashScreen is never called and no splash theme references it). - Added lifecycle-runtime-compose, needed for collectAsStateWithLifecycle. - Added mockk, turbine, kotlinx-coroutines-test and a testImplementation line for truth, which was previously androidTest-only. - opencsv is retained despite being unused: Phase 3 adopts it for the legacy CSV reader. Verified: clean assembleDebug, assembleRelease and lintDebug all green, 16 unit tests pass, all 65 net.zetetic classes still kept unrenamed by the newer R8, and onboarding still renders correctly with no crash in hi and ar on an API 36 emulator. Release APK drops 28M to 13M from the newer R8. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 35 ++- .../java/com/bhardwaj/passkey/di/AppModule.kt | 9 +- gradle/libs.versions.toml | 60 ++-- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 286 +++++++++++------- gradlew.bat | 41 +-- 7 files changed, 266 insertions(+), 170 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9625743..7de5eb2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,7 +2,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.ksp) alias(libs.plugins.hilt.android) @@ -14,7 +13,7 @@ plugins { android { namespace = "com.bhardwaj.passkey" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.bhardwaj.passkey" @@ -25,9 +24,21 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - val properties = Properties() - properties.load(project.rootProject.file("local.properties").inputStream()) - buildConfigField("String", "PASS_PHRASE", "\"${properties.getProperty("PASS_PHRASE")}\"") + // local.properties is gitignored, so a fresh clone or CI checkout will not have it. + // Loading it unconditionally threw FileNotFoundException and broke configuration. + val localProperties = Properties().apply { + val file = project.rootProject.file("local.properties") + if (file.exists()) file.inputStream().use { load(it) } + } + // Renamed from PASS_PHRASE: this value only exists to open pre-5.7 databases that were + // encrypted with the old build-time constant, so they can be re-keyed. Never use it for + // new vaults. Note that when the key is absent the historical builds embedded the literal + // string "null", which the migration also has to treat as a candidate. + buildConfigField( + "String", + "LEGACY_PASS_PHRASE", + "\"${localProperties.getProperty("PASS_PHRASE")}\"" + ) vectorDrawables { useSupportLibrary = true @@ -58,12 +69,12 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_22 - targetCompatibility = JavaVersion.VERSION_22 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlin { compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_22) + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } } buildFeatures { @@ -115,6 +126,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.compose) // Coroutines @@ -127,9 +139,6 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.hilt.navigation.compose) - // Splash Screen - implementation(libs.androidx.core.splashscreen) - // DataStore Preferences implementation(libs.datastore.preferences) @@ -164,6 +173,10 @@ dependencies { // Testing & Debugging testImplementation(libs.junit) + testImplementation(libs.google.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.turbine) androidTestImplementation(libs.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt index 118b320..0cd61de 100644 --- a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt +++ b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt @@ -30,7 +30,14 @@ object AppModule { name = Constants.PASS_KEY_DATABASE ) .addMigrations(MIGRATION_1_2) - .openHelperFactory(factory = SupportOpenHelperFactory(BuildConfig.PASS_PHRASE.toByteArray())) + // FIXME(5.7.0): this is a single build-time constant, identical for every install + // and recoverable from the APK, so the database is encrypted against a filesystem + // thief and nothing else. It is replaced in 5.7.0 by a per-install random key + // wrapped by an AndroidKeyStore key; this value then survives only to open and + // re-key pre-5.7 databases. + .openHelperFactory( + factory = SupportOpenHelperFactory(BuildConfig.LEGACY_PASS_PHRASE.toByteArray()) + ) .build() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd519ea..3207c2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,54 +1,52 @@ [versions] # Plugin versions -android_gradle_plugin = "8.13.1" -kotlin_plugin = "2.2.21" -hilt_plugin = "2.57.2" -ksp_plugin = "2.2.21-2.0.4" -google_services_plugin = "4.4.4" -firebase_crashlytics_plugin = "3.0.6" +android_gradle_plugin = "9.4.0" +kotlin_plugin = "2.3.21" +hilt_plugin = "2.60.1" +ksp_plugin = "2.3.12" +google_services_plugin = "4.5.0" +firebase_crashlytics_plugin = "3.0.8" firebase_perf_plugin = "2.0.2" # Dependency versions -core_ktx = "1.17.0" -activity_compose = "1.11.0" -compose_bom = "2025.11.00" -appcompat = "1.7.1" -lifecycle_runtime_ktx = "2.9.4" -lifecycle_viewmodel_compose = "2.9.4" -navigation_compose = "2.9.6" +core_ktx = "1.19.0" +activity_compose = "1.13.0" +compose_bom = "2026.09.00" +appcompat = "1.8.0" +lifecycle_runtime_ktx = "2.11.0" +lifecycle_viewmodel_compose = "2.11.0" +navigation_compose = "2.10.1" # Coroutines -coroutines_core = "1.10.2" -coroutines_android = "1.10.2" +coroutines_core = "1.11.0" +coroutines_android = "1.11.0" # Dagger - Hilt -hilt_android = "2.57.2" -hilt_compiler = "1.3.0" +hilt_android = "2.60.1" +hilt_compiler = "1.4.0" -# Splash Screen -splash_screen = "1.2.0" # DataStore Preferences -datastore_preferences = "1.1.7" +datastore_preferences = "1.2.1" # Biometric Authentication biometric = "1.1.0" # Room -room = "2.8.3" +room = "2.8.5" # SQL Cipher -sqlcipherAndroid = "4.11.0" -sqlcipherSqlite = "2.6.1" +sqlcipherAndroid = "4.19.0" +sqlcipherSqlite = "2.7.1" # Open CSV - Import/ Export Data opencsv = "5.12.0" # Reordering Lazy Columns -reorderable = "3.0.0" +reorderable = "3.1.0" # Firebase -firebase_bom = "34.6.0" +firebase_bom = "34.19.0" # In App Updates inAppUpdate = "2.1.0" @@ -58,7 +56,11 @@ junit = "4.13.2" test_ext_junit = "1.3.0" espresso_core = "3.7.0" compose_ui_test = "1.9.4" +# material-icons-extended is frozen upstream at 1.7.8 and is no longer BOM-managed. +material_icons = "1.7.8" truth = "1.4.5" +mockk = "1.14.11" +turbine = "1.2.1" [plugins] # Plugin definitions @@ -81,10 +83,11 @@ androidx_compose_ui = { module = "androidx.compose.ui:ui" } androidx_compose_ui_graphics = { module = "androidx.compose.ui:ui-graphics" } androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx_compose_material3 = { module = "androidx.compose.material3:material3" } -androidx_compose_material_icons_extended = { module = "androidx.compose.material:material-icons-extended" } +androidx_compose_material_icons_extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material_icons" } androidx_appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx_lifecycle_runtime_ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle_runtime_ktx" } androidx_lifecycle_viewmodel_compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle_viewmodel_compose" } +androidx_lifecycle_runtime_compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle_runtime_ktx" } androidx_navigation_compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation_compose" } # Coroutines @@ -97,8 +100,6 @@ hilt_android_compiler = { module = "com.google.dagger:hilt-android-compiler", ve hilt_compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt_compiler" } hilt_navigation_compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt_compiler" } -# Splash Screen -androidx_core_splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splash_screen" } # DataStore Preferences datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore_preferences" } @@ -138,5 +139,8 @@ test_ext_junit = { module = "androidx.test.ext:junit", version.ref = "test_ext_j espresso_core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso_core" } compose_ui_test_junit4 = { module = "androidx.compose.ui:ui-test-junit4" } google_truth = { module = "com.google.truth:truth", version.ref = "truth" } +kotlinx_coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines_core" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" } compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose_ui_test" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch literal 43764 zcma&OWmKeVvL#I6?i3D%6z=Zs?ofE*?rw#G$eqJB ziT4y8-Y@s9rkH0Tz>ll(^xkcTl)CY?rS&9VNd66Yc)g^6)JcWaY(5$5gt z8gr3SBXUTN;~cBgz&})qX%#!Fxom2Yau_`&8)+6aSN7YY+pS410rRUU*>J}qL0TnJ zRxt*7QeUqTh8j)Q&iavh<}L+$Jqz))<`IfKussVk%%Ah-Ti?Eo0hQH!rK%K=#EAw0 zwq@@~XNUXRnv8$;zv<6rCRJ6fPD^hfrh;0K?n z=p!u^3xOgWZ%f3+?+>H)9+w^$Tn1e;?UpVMJb!!;f)`6f&4|8mr+g)^@x>_rvnL0< zvD0Hu_N>$(Li7|Jgu0mRh&MV+<}`~Wi*+avM01E)Jtg=)-vViQKax!GeDc!xv$^mL z{#OVBA$U{(Zr8~Xm|cP@odkHC*1R8z6hcLY#N@3E-A8XEvpt066+3t9L_6Zg6j@9Q zj$$%~yO-OS6PUVrM2s)(T4#6=JpI_@Uz+!6=GdyVU?`!F=d;8#ZB@(5g7$A0(`eqY z8_i@3w$0*es5mrSjhW*qzrl!_LQWs4?VfLmo1Sd@Ztt53+etwzAT^8ow_*7Jp`Y|l z*UgSEwvxq+FYO!O*aLf-PinZYne7Ib6ny3u>MjQz=((r3NTEeU4=-i0LBq3H-VJH< z^>1RE3_JwrclUn9vb7HcGUaFRA0QHcnE;6)hnkp%lY1UII#WPAv?-;c?YH}LWB8Nl z{sx-@Z;QxWh9fX8SxLZk8;kMFlGD3Jc^QZVL4nO)1I$zQwvwM&_!kW+LMf&lApv#< zur|EyC|U@5OQuph$TC_ZU`{!vJp`13e9alaR0Dbn5ikLFH7>eIz4QbV|C=%7)F=qo z_>M&5N)d)7G(A%c>}UCrW!Ql_6_A{?R7&CL`;!KOb3 z8Z=$YkV-IF;c7zs{3-WDEFJzuakFbd*4LWd<_kBE8~BFcv}js_2OowRNzWCtCQ6&k z{&~Me92$m*@e0ANcWKuz)?YjB*VoSTx??-3Cc0l2U!X^;Bv@m87eKHukAljrD54R+ zE;@_w4NPe1>3`i5Qy*3^E9x#VB6?}v=~qIprrrd5|DFkg;v5ixo0IsBmik8=Y;zv2 z%Bcf%NE$a44bk^`i4VwDLTbX=q@j9;JWT9JncQ!+Y%2&HHk@1~*L8-{ZpY?(-a9J-1~<1ltr9i~D9`P{XTIFWA6IG8c4;6bFw*lzU-{+?b&%OcIoCiw00n>A1ra zFPE$y@>ebbZlf(sN_iWBzQKDV zmmaLX#zK!@ZdvCANfwV}9@2O&w)!5gSgQzHdk2Q`jG6KD7S+1R5&F)j6QTD^=hq&7 zHUW+r^da^%V(h(wonR(j?BOiC!;y=%nJvz?*aW&5E87qq;2z`EI(f zBJNNSMFF9U{sR-af5{IY&AtoGcoG)Iq-S^v{7+t0>7N(KRoPj;+2N5;9o_nxIGjJ@ z7bYQK)bX)vEhy~VL%N6g^NE@D5VtV+Q8U2%{ji_=6+i^G%xeskEhH>Sqr194PJ$fB zu1y^){?9Vkg(FY2h)3ZHrw0Z<@;(gd_dtF#6y_;Iwi{yX$?asr?0N0_B*CifEi7<6 zq`?OdQjCYbhVcg+7MSgIM|pJRu~`g?g3x?Tl+V}#$It`iD1j+!x+!;wS0+2e>#g?Z z*EA^k7W{jO1r^K~cD#5pamp+o@8&yw6;%b|uiT?{Wa=4+9<}aXWUuL#ZwN1a;lQod zW{pxWCYGXdEq9qAmvAB904}?97=re$>!I%wxPV#|f#@A*Y=qa%zHlDv^yWbR03%V0 zprLP+b(#fBqxI%FiF*-n8HtH6$8f(P6!H3V^ysgd8de-N(@|K!A< z^qP}jp(RaM9kQ(^K(U8O84?D)aU(g?1S8iWwe)gqpHCaFlJxb*ilr{KTnu4_@5{K- z)n=CCeCrPHO0WHz)dDtkbZfUfVBd?53}K>C5*-wC4hpDN8cGk3lu-ypq+EYpb_2H; z%vP4@&+c2p;thaTs$dc^1CDGlPG@A;yGR5@$UEqk6p58qpw#7lc<+W(WR;(vr(D>W z#(K$vE#uBkT=*q&uaZwzz=P5mjiee6>!lV?c}QIX%ZdkO1dHg>Fa#xcGT6~}1*2m9 zkc7l3ItD6Ie~o_aFjI$Ri=C!8uF4!Ky7iG9QTrxVbsQroi|r)SAon#*B*{}TB-?=@ z8~jJs;_R2iDd!$+n$%X6FO&PYS{YhDAS+U2o4su9x~1+U3z7YN5o0qUK&|g^klZ6X zj_vrM5SUTnz5`*}Hyts9ADwLu#x_L=nv$Z0`HqN`Zo=V>OQI)fh01n~*a%01%cx%0 z4LTFVjmW+ipVQv5rYcn3;d2o4qunWUY!p+?s~X~(ost@WR@r@EuDOSs8*MT4fiP>! zkfo^!PWJJ1MHgKS2D_hc?Bs?isSDO61>ebl$U*9*QY(b=i&rp3@3GV@z>KzcZOxip z^dzA~44;R~cnhWz7s$$v?_8y-k!DZys}Q?4IkSyR!)C0j$(Gm|t#e3|QAOFaV2}36 z?dPNY;@I=FaCwylc_;~kXlZsk$_eLkNb~TIl8QQ`mmH&$*zwwR8zHU*sId)rxHu*K z;yZWa8UmCwju%aSNLwD5fBl^b0Ux1%q8YR*uG`53Mi<`5uA^Dc6Ync)J3N7;zQ*75)hf%a@{$H+%S?SGT)ks60)?6j$ zspl|4Ad6@%-r1t*$tT(en!gIXTUDcsj?28ZEzz)dH)SV3bZ+pjMaW0oc~rOPZP@g! zb9E+ndeVO_Ib9c_>{)`01^`ZS198 z)(t=+{Azi11$eu%aU7jbwuQrO`vLOixuh~%4z@mKr_Oc;F%Uq01fA)^W&y+g16e?rkLhTxV!EqC%2}sx_1u7IBq|}Be&7WI z4I<;1-9tJsI&pQIhj>FPkQV9{(m!wYYV@i5h?A0#BN2wqlEwNDIq06|^2oYVa7<~h zI_OLan0Do*4R5P=a3H9`s5*>xU}_PSztg`+2mv)|3nIy=5#Z$%+@tZnr> zLcTI!Mxa`PY7%{;KW~!=;*t)R_sl<^b>eNO@w#fEt(tPMg_jpJpW$q_DoUlkY|uo> z0-1{ouA#;t%spf*7VjkK&$QrvwUERKt^Sdo)5@?qAP)>}Y!h4(JQ!7{wIdkA+|)bv z&8hBwoX4v|+fie}iTslaBX^i*TjwO}f{V)8*!dMmRPi%XAWc8<_IqK1jUsApk)+~R zNFTCD-h>M5Y{qTQ&0#j@I@tmXGj%rzhTW5%Bkh&sSc=$Fv;M@1y!zvYG5P2(2|(&W zlcbR1{--rJ&s!rB{G-sX5^PaM@3EqWVz_y9cwLR9xMig&9gq(voeI)W&{d6j1jh&< zARXi&APWE1FQWh7eoZjuP z;vdgX>zep^{{2%hem;e*gDJhK1Hj12nBLIJoL<=0+8SVEBx7!4Ea+hBY;A1gBwvY<)tj~T=H`^?3>zeWWm|LAwo*S4Z%bDVUe z6r)CH1H!(>OH#MXFJ2V(U(qxD{4Px2`8qfFLG+=a;B^~Te_Z!r3RO%Oc#ZAHKQxV5 zRYXxZ9T2A%NVJIu5Pu7!Mj>t%YDO$T@M=RR(~mi%sv(YXVl`yMLD;+WZ{vG9(@P#e zMo}ZiK^7^h6TV%cG+;jhJ0s>h&VERs=tuZz^Tlu~%d{ZHtq6hX$V9h)Bw|jVCMudd zwZ5l7In8NT)qEPGF$VSKg&fb0%R2RnUnqa){)V(X(s0U zkCdVZe6wy{+_WhZh3qLp245Y2RR$@g-!9PjJ&4~0cFSHMUn=>dapv)hy}|y91ZWTV zCh=z*!S3_?`$&-eZ6xIXUq8RGl9oK0BJw*TdU6A`LJqX9eS3X@F)g$jLkBWFscPhR zpCv8#KeAc^y>>Y$k^=r|K(DTC}T$0#jQBOwB#@`P6~*IuW_8JxCG}J4va{ zsZzt}tt+cv7=l&CEuVtjD6G2~_Meh%p4RGuY?hSt?(sreO_F}8r7Kp$qQdvCdZnDQ zxzc*qchE*E2=WK)^oRNa>Ttj`fpvF-JZ5tu5>X1xw)J@1!IqWjq)ESBG?J|ez`-Tc zi5a}GZx|w-h%5lNDE_3ho0hEXMoaofo#Z;$8|2;EDF&*L+e$u}K=u?pb;dv$SXeQM zD-~7P0i_`Wk$#YP$=hw3UVU+=^@Kuy$>6?~gIXx636jh{PHly_a2xNYe1l60`|y!7 z(u%;ILuW0DDJ)2%y`Zc~hOALnj1~txJtcdD#o4BCT68+8gZe`=^te6H_egxY#nZH&P*)hgYaoJ^qtmpeea`35Fw)cy!w@c#v6E29co8&D9CTCl%^GV|X;SpneSXzV~LXyRn-@K0Df z{tK-nDWA!q38M1~`xUIt_(MO^R(yNY#9@es9RQbY@Ia*xHhD&=k^T+ zJi@j2I|WcgW=PuAc>hs`(&CvgjL2a9Rx zCbZyUpi8NWUOi@S%t+Su4|r&UoU|ze9SVe7p@f1GBkrjkkq)T}X%Qo1g!SQ{O{P?m z-OfGyyWta+UCXH+-+(D^%kw#A1-U;?9129at7MeCCzC{DNgO zeSqsV>W^NIfTO~4({c}KUiuoH8A*J!Cb0*sp*w-Bg@YfBIPZFH!M}C=S=S7PLLcIG zs7K77g~W)~^|+mx9onzMm0qh(f~OsDTzVmRtz=aZTllgR zGUn~_5hw_k&rll<4G=G+`^Xlnw;jNYDJz@bE?|r866F2hA9v0-8=JO3g}IHB#b`hy zA42a0>{0L7CcabSD+F7?pGbS1KMvT{@1_@k!_+Ki|5~EMGt7T%u=79F)8xEiL5!EJ zzuxQ`NBliCoJMJdwu|);zRCD<5Sf?Y>U$trQ-;xj6!s5&w=9E7)%pZ+1Nh&8nCCwM zv5>Ket%I?cxr3vVva`YeR?dGxbG@pi{H#8@kFEf0Jq6~K4>kt26*bxv=P&jyE#e$| zDJB_~imk^-z|o!2njF2hL*|7sHCnzluhJjwLQGDmC)Y9 zr9ZN`s)uCd^XDvn)VirMgW~qfn1~SaN^7vcX#K1G`==UGaDVVx$0BQnubhX|{e z^i0}>k-;BP#Szk{cFjO{2x~LjK{^Upqd&<+03_iMLp0$!6_$@TbX>8U-f*-w-ew1?`CtD_0y_Lo|PfKi52p?`5$Jzx0E8`M0 zNIb?#!K$mM4X%`Ry_yhG5k@*+n4||2!~*+&pYLh~{`~o(W|o64^NrjP?-1Lgu?iK^ zTX6u3?#$?R?N!{599vg>G8RGHw)Hx&=|g4599y}mXNpM{EPKKXB&+m?==R3GsIq?G zL5fH={=zawB(sMlDBJ+{dgb)Vx3pu>L=mDV0{r1Qs{0Pn%TpopH{m(By4;{FBvi{I z$}x!Iw~MJOL~&)p93SDIfP3x%ROjg}X{Sme#hiJ&Yk&a;iR}V|n%PriZBY8SX2*;6 z4hdb^&h;Xz%)BDACY5AUsV!($lib4>11UmcgXKWpzRL8r2Srl*9Y(1uBQsY&hO&uv znDNff0tpHlLISam?o(lOp#CmFdH<6HmA0{UwfU#Y{8M+7od8b8|B|7ZYR9f<#+V|ZSaCQvI$~es~g(Pv{2&m_rKSB2QQ zMvT}$?Ll>V+!9Xh5^iy3?UG;dF-zh~RL#++roOCsW^cZ&({6q|?Jt6`?S8=16Y{oH zp50I7r1AC1(#{b`Aq5cw>ypNggHKM9vBx!W$eYIzD!4KbLsZGr2o8>g<@inmS3*>J zx8oG((8f!ei|M@JZB`p7+n<Q}?>h249<`7xJ?u}_n;Gq(&km#1ULN87CeTO~FY zS_Ty}0TgQhV zOh3T7{{x&LSYGQfKR1PDIkP!WnfC1$l+fs@Di+d4O=eVKeF~2fq#1<8hEvpwuqcaH z4A8u~r^gnY3u6}zj*RHjk{AHhrrDqaj?|6GaVJbV%o-nATw}ASFr!f`Oz|u_QPkR# z0mDudY1dZRlk@TyQ?%Eti=$_WNFtLpSx9=S^be{wXINp%MU?a`F66LNU<c;0&ngifmP9i;bj6&hdGMW^Kf8e6ZDXbQD&$QAAMo;OQ)G zW(qlHh;}!ZP)JKEjm$VZjTs@hk&4{?@+NADuYrr!R^cJzU{kGc1yB?;7mIyAWwhbeA_l_lw-iDVi7wcFurf5 z#Uw)A@a9fOf{D}AWE%<`s1L_AwpZ?F!Vac$LYkp<#A!!`XKaDC{A%)~K#5z6>Hv@V zBEqF(D5?@6r3Pwj$^krpPDCjB+UOszqUS;b2n>&iAFcw<*im2(b3|5u6SK!n9Sg4I z0KLcwA6{Mq?p%t>aW0W!PQ>iUeYvNjdKYqII!CE7SsS&Rj)eIw-K4jtI?II+0IdGq z2WT|L3RL?;GtGgt1LWfI4Ka`9dbZXc$TMJ~8#Juv@K^1RJN@yzdLS8$AJ(>g!U9`# zx}qr7JWlU+&m)VG*Se;rGisutS%!6yybi%B`bv|9rjS(xOUIvbNz5qtvC$_JYY+c& za*3*2$RUH8p%pSq>48xR)4qsp!Q7BEiJ*`^>^6INRbC@>+2q9?x(h0bpc>GaNFi$K zPH$6!#(~{8@0QZk=)QnM#I=bDx5vTvjm$f4K}%*s+((H2>tUTf==$wqyoI`oxI7>C z&>5fe)Yg)SmT)eA(|j@JYR1M%KixxC-Eceknf-;N=jJTwKvk#@|J^&5H0c+%KxHUI z6dQbwwVx3p?X<_VRVb2fStH?HH zFR@Mp=qX%#L3XL)+$PXKV|o|#DpHAoqvj6uQKe@M-mnhCSou7Dj4YuO6^*V`m)1lf z;)@e%1!Qg$10w8uEmz{ENb$^%u}B;J7sDd zump}onoD#!l=agcBR)iG!3AF0-63%@`K9G(CzKrm$VJ{v7^O9Ps7Zej|3m= zVXlR&yW6=Y%mD30G@|tf=yC7-#L!16Q=dq&@beWgaIL40k0n% z)QHrp2Jck#evLMM1RGt3WvQ936ZC9vEje0nFMfvmOHVI+&okB_K|l-;|4vW;qk>n~ z+|kk8#`K?x`q>`(f6A${wfw9Cx(^)~tX7<#TpxR#zYG2P+FY~mG{tnEkv~d6oUQA+ z&hNTL=~Y@rF`v-RZlts$nb$3(OL1&@Y11hhL9+zUb6)SP!;CD)^GUtUpCHBE`j1te zAGud@miCVFLk$fjsrcpjsadP__yj9iEZUW{Ll7PPi<$R;m1o!&Xdl~R_v0;oDX2z^!&8}zNGA}iYG|k zmehMd1%?R)u6R#<)B)1oe9TgYH5-CqUT8N7K-A-dm3hbm_W21p%8)H{O)xUlBVb+iUR}-v5dFaCyfSd zC6Bd7=N4A@+Bna=!-l|*_(nWGDpoyU>nH=}IOrLfS+-d40&(Wo*dDB9nQiA2Tse$R z;uq{`X7LLzP)%Y9aHa4YQ%H?htkWd3Owv&UYbr5NUDAH^<l@Z0Cx%`N+B*i!!1u>D8%;Qt1$ zE5O0{-`9gdDxZ!`0m}ywH!;c{oBfL-(BH<&SQ~smbcobU!j49O^f4&IIYh~f+hK*M zZwTp%{ZSAhMFj1qFaOA+3)p^gnXH^=)`NTYgTu!CLpEV2NF=~-`(}7p^Eof=@VUbd z_9U|8qF7Rueg&$qpSSkN%%%DpbV?8E8ivu@ensI0toJ7Eas^jyFReQ1JeY9plb^{m z&eQO)qPLZQ6O;FTr*aJq=$cMN)QlQO@G&%z?BKUs1&I^`lq>=QLODwa`(mFGC`0H< zOlc*|N?B5&!U6BuJvkL?s1&nsi$*5cCv7^j_*l&$-sBmRS85UIrE--7eD8Gr3^+o? zqG-Yl4S&E;>H>k^a0GdUI(|n1`ws@)1%sq2XBdK`mqrNq_b4N{#VpouCXLzNvjoFv zo9wMQ6l0+FT+?%N(ka*;%m~(?338bu32v26!{r)|w8J`EL|t$}TA4q_FJRX5 zCPa{hc_I(7TGE#@rO-(!$1H3N-C0{R$J=yPCXCtGk{4>=*B56JdXU9cQVwB`6~cQZ zf^qK21x_d>X%dT!!)CJQ3mlHA@ z{Prkgfs6=Tz%63$6Zr8CO0Ak3A)Cv#@BVKr&aiKG7RYxY$Yx>Bj#3gJk*~Ps-jc1l z;4nltQwwT4@Z)}Pb!3xM?+EW0qEKA)sqzw~!C6wd^{03-9aGf3Jmt=}w-*!yXupLf z;)>-7uvWN4Unn8b4kfIza-X=x*e4n5pU`HtgpFFd))s$C@#d>aUl3helLom+RYb&g zI7A9GXLRZPl}iQS*d$Azxg-VgcUr*lpLnbPKUV{QI|bsG{8bLG<%CF( zMoS4pRDtLVYOWG^@ox^h8xL~afW_9DcE#^1eEC1SVSb1BfDi^@g?#f6e%v~Aw>@w- zIY0k+2lGWNV|aA*e#`U3=+oBDmGeInfcL)>*!w|*;mWiKNG6wP6AW4-4imN!W)!hE zA02~S1*@Q`fD*+qX@f3!2yJX&6FsEfPditB%TWo3=HA;T3o2IrjS@9SSxv%{{7&4_ zdS#r4OU41~GYMiib#z#O;zohNbhJknrPPZS6sN$%HB=jUnlCO_w5Gw5EeE@KV>soy z2EZ?Y|4RQDDjt5y!WBlZ(8M)|HP<0YyG|D%RqD+K#e7-##o3IZxS^wQ5{Kbzb6h(i z#(wZ|^ei>8`%ta*!2tJzwMv+IFHLF`zTU8E^Mu!R*45_=ccqI};Zbyxw@U%a#2}%f zF>q?SrUa_a4H9l+uW8JHh2Oob>NyUwG=QH~-^ZebU*R@67DcXdz2{HVB4#@edz?B< z5!rQH3O0>A&ylROO%G^fimV*LX7>!%re{_Sm6N>S{+GW1LCnGImHRoF@csnFzn@P0 zM=jld0z%oz;j=>c7mMwzq$B^2mae7NiG}%>(wtmsDXkWk{?BeMpTrIt3Mizq?vRsf zi_WjNp+61uV(%gEU-Vf0;>~vcDhe(dzWdaf#4mH3o^v{0EWhj?E?$5v02sV@xL0l4 zX0_IMFtQ44PfWBbPYN#}qxa%=J%dlR{O!KyZvk^g5s?sTNycWYPJ^FK(nl3k?z-5t z39#hKrdO7V(@!TU)LAPY&ngnZ1MzLEeEiZznn7e-jLCy8LO zu^7_#z*%I-BjS#Pg-;zKWWqX-+Ly$T!4`vTe5ZOV0j?TJVA*2?*=82^GVlZIuH%9s zXiV&(T(QGHHah=s&7e|6y?g+XxZGmK55`wGV>@1U)Th&=JTgJq>4mI&Av2C z)w+kRoj_dA!;SfTfkgMPO>7Dw6&1*Hi1q?54Yng`JO&q->^CX21^PrU^JU#CJ_qhV zSG>afB%>2fx<~g8p=P8Yzxqc}s@>>{g7}F!;lCXvF#RV)^fyYb_)iKVCz1xEq=fJ| z0a7DMCK*FuP=NM*5h;*D`R4y$6cpW-E&-i{v`x=Jbk_xSn@2T3q!3HoAOB`@5Vg6) z{PW|@9o!e;v1jZ2{=Uw6S6o{g82x6g=k!)cFSC*oemHaVjg?VpEmtUuD2_J^A~$4* z3O7HsbA6wxw{TP5Kk)(Vm?gKo+_}11vbo{Tp_5x79P~#F)ahQXT)tSH5;;14?s)On zel1J>1x>+7;g1Iz2FRpnYz;sD0wG9Q!vuzE9yKi3@4a9Nh1!GGN?hA)!mZEnnHh&i zf?#ZEN2sFbf~kV;>K3UNj1&vFhc^sxgj8FCL4v>EOYL?2uuT`0eDH}R zmtUJMxVrV5H{L53hu3#qaWLUa#5zY?f5ozIn|PkMWNP%n zWB5!B0LZB0kLw$k39=!akkE9Q>F4j+q434jB4VmslQ;$ zKiO#FZ`p|dKS716jpcvR{QJkSNfDVhr2%~eHrW;fU45>>snr*S8Vik-5eN5k*c2Mp zyxvX&_cFbB6lODXznHHT|rsURe2!swomtrqc~w5 zymTM8!w`1{04CBprR!_F{5LB+2_SOuZN{b*!J~1ZiPpP-M;);!ce!rOPDLtgR@Ie1 zPreuqm4!H)hYePcW1WZ0Fyaqe%l}F~Orr)~+;mkS&pOhP5Ebb`cnUt!X_QhP4_4p( z8YKQCDKGIy>?WIFm3-}Br2-N`T&FOi?t)$hjphB9wOhBXU#Hb+zm&We_-O)s(wc`2 z8?VsvU;J>Ju7n}uUb3s1yPx_F*|FlAi=Ge=-kN?1;`~6szP%$3B0|8Sqp%ebM)F8v zADFrbeT0cgE>M0DMV@_Ze*GHM>q}wWMzt|GYC%}r{OXRG3Ij&<+nx9;4jE${Fj_r* z`{z1AW_6Myd)i6e0E-h&m{{CvzH=Xg!&(bLYgRMO_YVd8JU7W+7MuGWNE=4@OvP9+ zxi^vqS@5%+#gf*Z@RVyU9N1sO-(rY$24LGsg1>w>s6ST^@)|D9>cT50maXLUD{Fzf zt~tp{OSTEKg3ZSQyQQ5r51){%=?xlZ54*t1;Ow)zLe3i?8tD8YyY^k%M)e`V*r+vL zPqUf&m)U+zxps+NprxMHF{QSxv}>lE{JZETNk1&F+R~bp{_T$dbXL2UGnB|hgh*p4h$clt#6;NO~>zuyY@C-MD@)JCc5XrYOt`wW7! z_ti2hhZBMJNbn0O-uTxl_b6Hm313^fG@e;RrhIUK9@# z+DHGv_Ow$%S8D%RB}`doJjJy*aOa5mGHVHz0e0>>O_%+^56?IkA5eN+L1BVCp4~m=1eeL zb;#G!#^5G%6Mw}r1KnaKsLvJB%HZL)!3OxT{k$Yo-XrJ?|7{s4!H+S2o?N|^Z z)+?IE9H7h~Vxn5hTis^3wHYuOU84+bWd)cUKuHapq=&}WV#OxHpLab`NpwHm8LmOo zjri+!k;7j_?FP##CpM+pOVx*0wExEex z@`#)K<-ZrGyArK;a%Km`^+We|eT+#MygHOT6lXBmz`8|lyZOwL1+b+?Z$0OhMEp3R z&J=iRERpv~TC=p2-BYLC*?4 zxvPs9V@g=JT0>zky5Poj=fW_M!c)Xxz1<=&_ZcL=LMZJqlnO1P^xwGGW*Z+yTBvbV z-IFe6;(k1@$1;tS>{%pXZ_7w+i?N4A2=TXnGf=YhePg8bH8M|Lk-->+w8Y+FjZ;L=wSGwxfA`gqSn)f(XNuSm>6Y z@|#e-)I(PQ^G@N`%|_DZSb4_pkaEF0!-nqY+t#pyA>{9^*I-zw4SYA1_z2Bs$XGUZbGA;VeMo%CezHK0lO={L%G)dI-+8w?r9iexdoB{?l zbJ}C?huIhWXBVs7oo{!$lOTlvCLZ_KN1N+XJGuG$rh<^eUQIqcI7^pmqhBSaOKNRq zrx~w^?9C?*&rNwP_SPYmo;J-#!G|{`$JZK7DxsM3N^8iR4vvn>E4MU&Oe1DKJvLc~ zCT>KLZ1;t@My zRj_2hI^61T&LIz)S!+AQIV23n1>ng+LUvzv;xu!4;wpqb#EZz;F)BLUzT;8UA1x*6vJ zicB!3Mj03s*kGV{g`fpC?V^s(=JG-k1EMHbkdP4P*1^8p_TqO|;!Zr%GuP$8KLxuf z=pv*H;kzd;P|2`JmBt~h6|GxdU~@weK5O=X&5~w$HpfO}@l-T7@vTCxVOwCkoPQv8 z@aV_)I5HQtfs7^X=C03zYmH4m0S!V@JINm6#(JmZRHBD?T!m^DdiZJrhKpBcur2u1 zf9e4%k$$vcFopK5!CC`;ww(CKL~}mlxK_Pv!cOsFgVkNIghA2Au@)t6;Y3*2gK=5d z?|@1a)-(sQ%uFOmJ7v2iG&l&m^u&^6DJM#XzCrF%r>{2XKyxLD2rgWBD;i(!e4InDQBDg==^z;AzT2z~OmV0!?Z z0S9pX$+E;w3WN;v&NYT=+G8hf=6w0E1$0AOr61}eOvE8W1jX%>&Mjo7&!ulawgzLH zbcb+IF(s^3aj12WSi#pzIpijJJzkP?JzRawnxmNDSUR#7!29vHULCE<3Aa#be}ie~d|!V+ z%l~s9Odo$G&fH!t!+`rUT0T9DulF!Yq&BfQWFZV1L9D($r4H(}Gnf6k3^wa7g5|Ws zj7%d`!3(0bb55yhC6@Q{?H|2os{_F%o=;-h{@Yyyn*V7?{s%Grvpe!H^kl6tF4Zf5 z{Jv1~yZ*iIWL_9C*8pBMQArfJJ0d9Df6Kl#wa}7Xa#Ef_5B7=X}DzbQXVPfCwTO@9+@;A^Ti6il_C>g?A-GFwA0#U;t4;wOm-4oS})h z5&on>NAu67O?YCQr%7XIzY%LS4bha9*e*4bU4{lGCUmO2UQ2U)QOqClLo61Kx~3dI zmV3*(P6F_Tr-oP%x!0kTnnT?Ep5j;_IQ^pTRp=e8dmJtI4YgWd0}+b2=ATkOhgpXe z;jmw+FBLE}UIs4!&HflFr4)vMFOJ19W4f2^W(=2)F%TAL)+=F>IE$=e=@j-*bFLSg z)wf|uFQu+!=N-UzSef62u0-C8Zc7 zo6@F)c+nZA{H|+~7i$DCU0pL{0Ye|fKLuV^w!0Y^tT$isu%i1Iw&N|tX3kwFKJN(M zXS`k9js66o$r)x?TWL}Kxl`wUDUpwFx(w4Yk%49;$sgVvT~n8AgfG~HUcDt1TRo^s zdla@6heJB@JV z!vK;BUMznhzGK6PVtj0)GB=zTv6)Q9Yt@l#fv7>wKovLobMV-+(8)NJmyF8R zcB|_K7=FJGGn^X@JdFaat0uhKjp3>k#^&xE_}6NYNG?kgTp>2Iu?ElUjt4~E-?`Du z?mDCS9wbuS%fU?5BU@Ijx>1HG*N?gIP+<~xE4u=>H`8o((cS5M6@_OK%jSjFHirQK zN9@~NXFx*jS{<|bgSpC|SAnA@I)+GB=2W|JJChLI_mx+-J(mSJ!b)uUom6nH0#2^(L@JBlV#t zLl?j54s`Y3vE^c_3^Hl0TGu*tw_n?@HyO@ZrENxA+^!)OvUX28gDSF*xFtQzM$A+O zCG=n#6~r|3zt=8%GuG} z<#VCZ%2?3Q(Ad#Y7GMJ~{U3>E{5e@z6+rgZLX{Cxk^p-7dip^d29;2N1_mm4QkASo z-L`GWWPCq$uCo;X_BmGIpJFBlhl<8~EG{vOD1o|X$aB9KPhWO_cKiU*$HWEgtf=fn zsO%9bp~D2c@?*K9jVN@_vhR03>M_8h!_~%aN!Cnr?s-!;U3SVfmhRwk11A^8Ns`@KeE}+ zN$H}a1U6E;*j5&~Og!xHdfK5M<~xka)x-0N)K_&e7AjMz`toDzasH+^1bZlC!n()crk9kg@$(Y{wdKvbuUd04N^8}t1iOgsKF zGa%%XWx@WoVaNC1!|&{5ZbkopFre-Lu(LCE5HWZBoE#W@er9W<>R=^oYxBvypN#x3 zq#LC8&q)GFP=5^-bpHj?LW=)-g+3_)Ylps!3^YQ{9~O9&K)xgy zMkCWaApU-MI~e^cV{Je75Qr7eF%&_H)BvfyKL=gIA>;OSq(y z052BFz3E(Prg~09>|_Z@!qj}@;8yxnw+#Ej0?Rk<y}4ghbD569B{9hSFr*^ygZ zr6j7P#gtZh6tMk6?4V$*Jgz+#&ug;yOr>=qdI#9U&^am2qoh4Jy}H2%a|#Fs{E(5r z%!ijh;VuGA6)W)cJZx+;9Bp1LMUzN~x_8lQ#D3+sL{be-Jyeo@@dv7XguJ&S5vrH` z>QxOMWn7N-T!D@1(@4>ZlL^y5>m#0!HKovs12GRav4z!>p(1~xok8+_{| z#Ae4{9#NLh#Vj2&JuIn5$d6t@__`o}umFo(n0QxUtd2GKCyE+erwXY?`cm*h&^9*8 zJ+8x6fRZI-e$CRygofIQN^dWysCxgkyr{(_oBwwSRxZora1(%(aC!5BTtj^+YuevI zx?)H#(xlALUp6QJ!=l9N__$cxBZ5p&7;qD3PsXRFVd<({Kh+mShFWJNpy`N@ab7?9 zv5=klvCJ4bx|-pvOO2-+G)6O?$&)ncA#Urze2rlBfp#htudhx-NeRnJ@u%^_bfw4o z4|{b8SkPV3b>Wera1W(+N@p9H>dc6{cnkh-sgr?e%(YkWvK+0YXVwk0=d`)}*47*B z5JGkEdVix!w7-<%r0JF~`ZMMPe;f0EQHuYHxya`puazyph*ZSb1mJAt^k4549BfS; zK7~T&lRb=W{s&t`DJ$B}s-eH1&&-wEOH1KWsKn0a(ZI+G!v&W4A*cl>qAvUv6pbUR z#(f#EKV8~hk&8oayBz4vaswc(?qw1vn`yC zZQDl2PCB-&Uu@g9ZQHhO+v(W0bNig{-k0;;`+wM@#@J)8r?qOYs#&vUna8ILxN7S{ zp1s41KnR8miQJtJtOr|+qk}wrLt+N*z#5o`TmD1)E&QD(Vh&pjZJ_J*0!8dy_ z>^=@v=J)C`x&gjqAYu`}t^S=DFCtc0MkBU2zf|69?xW`Ck~(6zLD)gSE{7n~6w8j_ zoH&~$ED2k5-yRa0!r8fMRy z;QjBYUaUnpd}mf%iVFPR%Dg9!d>g`01m~>2s))`W|5!kc+_&Y>wD@@C9%>-lE`WB0 zOIf%FVD^cj#2hCkFgi-fgzIfOi+ya)MZK@IZhHT5FVEaSbv-oDDs0W)pA0&^nM0TW zmgJmd7b1R7b0a`UwWJYZXp4AJPteYLH>@M|xZFKwm!t3D3&q~av?i)WvAKHE{RqpD{{%OhYkK?47}+}` zrR2(Iv9bhVa;cDzJ%6ntcSbx7v7J@Y4x&+eWSKZ*eR7_=CVIUSB$^lfYe@g+p|LD{ zPSpQmxx@b$%d!05|H}WzBT4_cq?@~dvy<7s&QWtieJ9)hd4)$SZz}#H2UTi$CkFWW|I)v_-NjuH!VypONC=1`A=rm_jfzQ8Fu~1r8i{q-+S_j$ z#u^t&Xnfi5tZtl@^!fUJhx@~Cg0*vXMK}D{>|$#T*+mj(J_@c{jXBF|rm4-8%Z2o! z2z0o(4%8KljCm^>6HDK!{jI7p+RAPcty_~GZ~R_+=+UzZ0qzOwD=;YeZt*?3%UGdr z`c|BPE;yUbnyARUl&XWSNJ<+uRt%!xPF&K;(l$^JcA_CMH6)FZt{>6ah$|(9$2fc~ z=CD00uHM{qv;{Zk9FR0~u|3|Eiqv9?z2#^GqylT5>6JNZwKqKBzzQpKU2_pmtD;CT zi%Ktau!Y2Tldfu&b0UgmF(SSBID)15*r08eoUe#bT_K-G4VecJL2Pa=6D1K6({zj6 za(2Z{r!FY5W^y{qZ}08+h9f>EKd&PN90f}Sc0ejf%kB4+f#T8Q1=Pj=~#pi$U zp#5rMR%W25>k?<$;$x72pkLibu1N|jX4cWjD3q^Pk3js!uK6h7!dlvw24crL|MZs_ zb%Y%?Fyp0bY0HkG^XyS76Ts*|Giw{31LR~+WU5NejqfPr73Rp!xQ1mLgq@mdWncLy z%8}|nzS4P&`^;zAR-&nm5f;D-%yNQPwq4N7&yULM8bkttkD)hVU>h>t47`{8?n2&4 zjEfL}UEagLUYwdx0sB2QXGeRmL?sZ%J!XM`$@ODc2!y|2#7hys=b$LrGbvvjx`Iqi z&RDDm3YBrlKhl`O@%%&rhLWZ*ABFz2nHu7k~3@e4)kO3%$=?GEFUcCF=6-1n!x^vmu+Ai*amgXH+Rknl6U>#9w;A} zn2xanZSDu`4%%x}+~FG{Wbi1jo@wqBc5(5Xl~d0KW(^Iu(U3>WB@-(&vn_PJt9{1`e9Iic@+{VPc`vP776L*viP{wYB2Iff8hB%E3|o zGMOu)tJX!`qJ}ZPzq7>=`*9TmETN7xwU;^AmFZ-ckZjV5B2T09pYliaqGFY|X#E-8 z20b>y?(r-Fn5*WZ-GsK}4WM>@TTqsxvSYWL6>18q8Q`~JO1{vLND2wg@58OaU!EvT z1|o+f1mVXz2EKAbL!Q=QWQKDZpV|jznuJ}@-)1&cdo z^&~b4Mx{*1gurlH;Vhk5g_cM&6LOHS2 zRkLfO#HabR1JD4Vc2t828dCUG#DL}f5QDSBg?o)IYYi@_xVwR2w_ntlpAW0NWk$F1 z$If?*lP&Ka1oWfl!)1c3fl`g*lMW3JOn#)R1+tfwrs`aiFUgz3;XIJ>{QFxLCkK30 zNS-)#DON3yb!7LBHQJ$)4y%TN82DC2-9tOIqzhZ27@WY^<6}vXCWcR5iN{LN8{0u9 zNXayqD=G|e?O^*ms*4P?G%o@J1tN9_76e}E#66mr89%W_&w4n66~R;X_vWD(oArwj z4CpY`)_mH2FvDuxgT+akffhX0b_slJJ*?Jn3O3~moqu2Fs1oL*>7m=oVek2bnprnW zixkaIFU%+3XhNA@@9hyhFwqsH2bM|`P?G>i<-gy>NflhrN{$9?LZ1ynSE_Mj0rADF zhOz4FnK}wpLmQuV zgO4_Oz9GBu_NN>cPLA=`SP^$gxAnj;WjJnBi%Q1zg`*^cG;Q)#3Gv@c^j6L{arv>- zAW%8WrSAVY1sj$=umcAf#ZgC8UGZGoamK}hR7j6}i8#np8ruUlvgQ$j+AQglFsQQq zOjyHf22pxh9+h#n$21&$h?2uq0>C9P?P=Juw0|;oE~c$H{#RGfa>| zj)Iv&uOnaf@foiBJ}_;zyPHcZt1U~nOcNB{)og8Btv+;f@PIT*xz$x!G?u0Di$lo7 zOugtQ$Wx|C($fyJTZE1JvR~i7LP{ zbdIwqYghQAJi9p}V&$=*2Azev$6K@pyblphgpv8^9bN!?V}{BkC!o#bl&AP!3DAjM zmWFsvn2fKWCfjcAQmE+=c3Y7j@#7|{;;0f~PIodmq*;W9Fiak|gil6$w3%b_Pr6K_ zJEG@&!J%DgBZJDCMn^7mk`JV0&l07Bt`1ymM|;a)MOWz*bh2#d{i?SDe9IcHs7 zjCrnyQ*Y5GzIt}>`bD91o#~5H?4_nckAgotN{2%!?wsSl|LVmJht$uhGa+HiH>;av z8c?mcMYM7;mvWr6noUR{)gE!=i7cZUY7e;HXa221KkRoc2UB>s$Y(k%NzTSEr>W(u z<(4mcc)4rB_&bPzX*1?*ra%VF}P1nwiP5cykJ&W{!OTlz&Td0pOkVp+wc z@k=-Hg=()hNg=Q!Ub%`BONH{ z_=ZFgetj@)NvppAK2>8r!KAgi>#%*7;O-o9MOOfQjV-n@BX6;Xw;I`%HBkk20v`qoVd0)}L6_49y1IhR z_OS}+eto}OPVRn*?UHC{eGyFU7JkPz!+gX4P>?h3QOwGS63fv4D1*no^6PveUeE5% zlehjv_3_^j^C({a2&RSoVlOn71D8WwMu9@Nb@=E_>1R*ve3`#TF(NA0?d9IR_tm=P zOP-x;gS*vtyE1Cm zG0L?2nRUFj#aLr-R1fX*$sXhad)~xdA*=hF3zPZhha<2O$Ps+F07w*3#MTe?)T8|A!P!v+a|ot{|^$q(TX`35O{WI0RbU zCj?hgOv=Z)xV?F`@HKI11IKtT^ocP78cqHU!YS@cHI@{fPD?YXL)?sD~9thOAv4JM|K8OlQhPXgnevF=F7GKD2#sZW*d za}ma31wLm81IZxX(W#A9mBvLZr|PoLnP>S4BhpK8{YV_}C|p<)4#yO{#ISbco92^3 zv&kCE(q9Wi;9%7>>PQ!zSkM%qqqLZW7O`VXvcj;WcJ`2~v?ZTYB@$Q&^CTfvy?1r^ z;Cdi+PTtmQwHX_7Kz?r#1>D zS5lWU(Mw_$B&`ZPmqxpIvK<~fbXq?x20k1~9az-Q!uR78mCgRj*eQ>zh3c$W}>^+w^dIr-u{@s30J=)1zF8?Wn|H`GS<=>Om|DjzC{}Jt?{!fSJe*@$H zg>wFnlT)k#T?LslW zu$^7Uy~$SQ21cE?3Ijl+bLfuH^U5P^$@~*UY#|_`uvAIe(+wD2eF}z_y!pvomuVO; zS^9fbdv)pcm-B@CW|Upm<7s|0+$@@<&*>$a{aW+oJ%f+VMO<#wa)7n|JL5egEgoBv zl$BY(NQjE0#*nv=!kMnp&{2Le#30b)Ql2e!VkPLK*+{jv77H7)xG7&=aPHL7LK9ER z5lfHxBI5O{-3S?GU4X6$yVk>lFn;ApnwZybdC-GAvaznGW-lScIls-P?Km2mF>%B2 zkcrXTk+__hj-3f48U%|jX9*|Ps41U_cd>2QW81Lz9}%`mTDIhE)jYI$q$ma7Y-`>% z8=u+Oftgcj%~TU}3nP8&h7k+}$D-CCgS~wtWvM|UU77r^pUw3YCV80Ou*+bH0!mf0 zxzUq4ed6y>oYFz7+l18PGGzhB^pqSt)si=9M>~0(Bx9*5r~W7sa#w+_1TSj3Jn9mW zMuG9BxN=}4645Cpa#SVKjFst;9UUY@O<|wpnZk$kE+to^4!?0@?Cwr3(>!NjYbu?x z1!U-?0_O?k!NdM^-rIQ8p)%?M+2xkhltt*|l=%z2WFJhme7*2xD~@zk#`dQR$6Lmd zb3LOD4fdt$Cq>?1<%&Y^wTWX=eHQ49Xl_lFUA(YQYHGHhd}@!VpYHHm=(1-O=yfK#kKe|2Xc*9}?BDFN zD7FJM-AjVi)T~OG)hpSWqH>vlb41V#^G2B_EvYlWhDB{Z;Q9-0)ja(O+By`31=biA zG&Fs#5!%_mHi|E4Nm$;vVQ!*>=_F;ZC=1DTPB#CICS5fL2T3XmzyHu?bI;m7D4@#; ztr~;dGYwb?m^VebuULtS4lkC_7>KCS)F@)0OdxZIFZp@FM_pHnJes8YOvwB|++#G( z&dm*OP^cz95Wi15vh`Q+yB>R{8zqEhz5of>Po$9LNE{xS<)lg2*roP*sQ}3r3t<}; zPbDl{lk{pox~2(XY5=qg0z!W-x^PJ`VVtz$git7?)!h>`91&&hESZy1KCJ2nS^yMH z!=Q$eTyRi68rKxdDsdt+%J_&lapa{ds^HV9Ngp^YDvtq&-Xp}60B_w@Ma>_1TTC;^ zpbe!#gH}#fFLkNo#|`jcn?5LeUYto%==XBk6Ik0kc4$6Z+L3x^4=M6OI1=z5u#M%0 z0E`kevJEpJjvvN>+g`?gtnbo$@p4VumliZV3Z%CfXXB&wPS^5C+7of2tyVkMwNWBiTE2 z8CdPu3i{*vR-I(NY5syRR}I1TJOV@DJy-Xmvxn^IInF>Tx2e)eE9jVSz69$6T`M9-&om!T+I znia!ZWJRB28o_srWlAxtz4VVft8)cYloIoVF=pL zugnk@vFLXQ_^7;%hn9x;Vq?lzg7%CQR^c#S)Oc-8d=q_!2ZVH764V z!wDKSgP}BrVV6SfCLZnYe-7f;igDs9t+K*rbMAKsp9L$Kh<6Z;e7;xxced zn=FGY<}CUz31a2G}$Q(`_r~75PzM4l_({Hg&b@d8&jC}B?2<+ed`f#qMEWi z`gm!STV9E4sLaQX+sp5Nu9*;9g12naf5?=P9p@H@f}dxYprH+3ju)uDFt^V{G0APn zS;16Dk{*fm6&BCg#2vo?7cbkkI4R`S9SSEJ=#KBk3rl69SxnCnS#{*$!^T9UUmO#&XXKjHKBqLdt^3yVvu8yn|{ zZ#%1CP)8t-PAz(+_g?xyq;C2<9<5Yy<~C74Iw(y>uUL$+$mp(DRcCWbCKiGCZw@?_ zdomfp+C5xt;j5L@VfhF*xvZdXwA5pcdsG>G<8II-|1dhAgzS&KArcb0BD4ZZ#WfiEY{hkCq5%z9@f|!EwTm;UEjKJsUo696V>h zy##eXYX}GUu%t{Gql8vVZKkNhQeQ4C%n|RmxL4ee5$cgwlU+?V7a?(jI#&3wid+Kz5+x^G!bb#$q>QpR#BZ}Xo5UW^ zD&I`;?(a}Oys7-`I^|AkN?{XLZNa{@27Dv^s4pGowuyhHuXc zuctKG2x0{WCvg_sGN^n9myJ}&FXyGmUQnW7fR$=bj$AHR88-q$D!*8MNB{YvTTEyS zn22f@WMdvg5~o_2wkjItJN@?mDZ9UUlat2zCh(zVE=dGi$rjXF7&}*sxac^%HFD`Y zTM5D3u5x**{bW!68DL1A!s&$2XG@ytB~dX-?BF9U@XZABO`a|LM1X3HWCllgl0+uL z04S*PX$%|^WAq%jkzp~%9HyYIF{Ym?k)j3nMwPZ=hlCg9!G+t>tf0o|J2%t1 ztC+`((dUplgm3`+0JN~}&FRRJ3?l*>Y&TfjS>!ShS`*MwO{WIbAZR#<%M|4c4^dY8 z{Rh;-!qhY=dz5JthbWoovLY~jNaw>%tS4gHVlt5epV8ekXm#==Po$)}mh^u*cE>q7*kvX&gq)(AHoItMYH6^s6f(deNw%}1=7O~bTHSj1rm2|Cq+3M z93djjdomWCTCYu!3Slx2bZVy#CWDozNedIHbqa|otsUl+ut?>a;}OqPfQA05Yim_2 zs@^BjPoFHOYNc6VbNaR5QZfSMh2S*`BGwcHMM(1@w{-4jVqE8Eu0Bi%d!E*^Rj?cR z7qgxkINXZR)K^=fh{pc0DCKtrydVbVILI>@Y0!Jm>x-xM!gu%dehm?cC6ok_msDVA*J#{75%4IZt}X|tIVPReZS#aCvuHkZxc zHVMtUhT(wp09+w9j9eRqz~LtuSNi2rQx_QgQ(}jBt7NqyT&ma61ldD(s9x%@q~PQl zp6N*?=N$BtvjQ_xIT{+vhb1>{pM0Arde0!X-y))A4znDrVx8yrP3B1(7bKPE5jR@5 zwpzwT4cu~_qUG#zYMZ_!2Tkl9zP>M%cy>9Y(@&VoB84#%>amTAH{(hL4cDYt!^{8L z645F>BWO6QaFJ-{C-i|-d%j7#&7)$X7pv#%9J6da#9FB5KyDhkA+~)G0^87!^}AP>XaCSScr;kL;Z%RSPD2CgoJ;gpYT5&6NUK$86$T?jRH=w8nI9Z534O?5fk{kd z`(-t$8W|#$3>xoMfXvV^-A(Q~$8SKDE^!T;J+rQXP71XZ(kCCbP%bAQ1|%$%Ov9_a zyC`QP3uPvFoBqr_+$HenHklqyIr>PU_Fk5$2C+0eYy^~7U&(!B&&P2%7#mBUhM!z> z_B$Ko?{Pf6?)gpYs~N*y%-3!1>o-4;@1Zz9VQHh)j5U1aL-Hyu@1d?X;jtDBNk*vMXPn@ z+u@wxHN*{uHR!*g*4Xo&w;5A+=Pf9w#PeZ^x@UD?iQ&${K2c}UQgLRik-rKM#Y5rdDphdcNTF~cCX&9ViRP}`>L)QA4zNXeG)KXFzSDa6 zd^St;inY6J_i=5mcGTx4_^Ys`M3l%Q==f>{8S1LEHn{y(kbxn5g1ezt4CELqy)~TV6{;VW>O9?5^ ztcoxHRa0jQY7>wwHWcxA-BCwzsP>63Kt&3fy*n#Cha687CQurXaRQnf5wc9o8v7Rw zNwGr2fac;Wr-Ldehn7tF^(-gPJwPt@VR1f;AmKgxN&YPL;j=0^xKM{!wuU|^mh3NE zy35quf}MeL!PU;|{OW_x$TBothLylT-J>_x6p}B_jW1L>k)ps6n%7Rh z96mPkJIM0QFNYUM2H}YF5bs%@Chs6#pEnloQhEl?J-)es!(SoJpEPoMTdgA14-#mC zghayD-DJWtUu`TD8?4mR)w5E`^EHbsz2EjH5aQLYRcF{l7_Q5?CEEvzDo(zjh|BKg z3aJl_n#j&eFHsUw4~lxqnr!6NL*se)6H=A+T1e3xUJGQrd}oSPwSy5+$tt{2t5J5@(lFxl43amsARG74iyNC}uuS zd2$=(r6RdamdGx^eatX@F2D8?U23tDpR+Os?0Gq2&^dF+$9wiWf?=mDWfjo4LfRwL zI#SRV9iSz>XCSgEj!cW&9H-njJopYiYuq|2w<5R2!nZ27DyvU4UDrHpoNQZiGPkp@ z1$h4H46Zn~eqdj$pWrv;*t!rTYTfZ1_bdkZmVVIRC21YeU$iS-*XMNK`#p8Z_DJx| zk3Jssf^XP7v0X?MWFO{rACltn$^~q(M9rMYoVxG$15N;nP)A98k^m3CJx8>6}NrUd@wp-E#$Q0uUDQT5GoiK_R{ z<{`g;8s>UFLpbga#DAf%qbfi`WN1J@6IA~R!YBT}qp%V-j!ybkR{uY0X|x)gmzE0J z&)=eHPjBxJvrZSOmt|)hC+kIMI;qgOnuL3mbNR0g^<%|>9x7>{}>a2qYSZAGPt4it?8 zNcLc!Gy0>$jaU?}ZWxK78hbhzE+etM`67*-*x4DN>1_&{@5t7_c*n(qz>&K{Y?10s zXsw2&nQev#SUSd|D8w7ZD2>E<%g^; zV{yE_O}gq?Q|zL|jdqB^zcx7vo(^})QW?QKacx$yR zhG|XH|8$vDZNIfuxr-sYFR{^csEI*IM#_gd;9*C+SysUFejP0{{z7@P?1+&_o6=7V|EJLQun^XEMS)w(=@eMi5&bbH*a0f;iC~2J74V2DZIlLUHD&>mlug5+v z6xBN~8-ovZylyH&gG#ptYsNlT?-tzOh%V#Y33zlsJ{AIju`CjIgf$@gr8}JugRq^c zAVQ3;&uGaVlVw}SUSWnTkH_6DISN&k2QLMBe9YU=sA+WiX@z)FoSYX`^k@B!j;ZeC zf&**P?HQG6Rk98hZ*ozn6iS-dG}V>jQhb3?4NJB*2F?6N7Nd;EOOo;xR7acylLaLy z9)^lykX39d@8@I~iEVar4jmjjLWhR0d=EB@%I;FZM$rykBNN~jf>#WbH4U{MqhhF6 zU??@fSO~4EbU4MaeQ_UXQcFyO*Rae|VAPLYMJEU`Q_Q_%s2*>$#S^)&7er+&`9L=1 z4q4ao07Z2Vsa%(nP!kJ590YmvrWg+YrgXYs_lv&B5EcoD`%uL79WyYA$0>>qi6ov7 z%`ia~J^_l{p39EY zv>>b}Qs8vxsu&WcXEt8B#FD%L%ZpcVtY!rqVTHe;$p9rbb5O{^rFMB>auLn-^;s+-&P1#h~mf~YLg$8M9 zZ4#87;e-Y6x6QO<{McUzhy(%*6| z)`D~A(TJ$>+0H+mct(jfgL4x%^oC^T#u(bL)`E2tBI#V1kSikAWmOOYrO~#-cc_8! zCe|@1&mN2{*ceeiBldHCdrURk4>V}79_*TVP3aCyV*5n@jiNbOm+~EQ_}1#->_tI@ zqXv+jj2#8xJtW508rzFrYcJxoek@iW6SR@1%a%Bux&;>25%`j3UI`0DaUr7l79`B1 zqqUARhW1^h6=)6?;@v>xrZNM;t}{yY3P@|L}ey@gG( z9r{}WoYN(9TW&dE2dEJIXkyHA4&pU6ki=rx&l2{DLGbVmg4%3Dlfvn!GB>EVaY_%3+Df{fBiqJV>~Xf8A0aqUjgpa} zoF8YXO&^_x*Ej}nw-$-F@(ddB>%RWoPUj?p8U{t0=n>gAI83y<9Ce@Q#3&(soJ{64 z37@Vij1}5fmzAuIUnXX`EYe;!H-yTVTmhAy;y8VZeB#vD{vw9~P#DiFiKQ|kWwGFZ z=jK;JX*A;Jr{#x?n8XUOLS;C%f|zj-7vXtlf_DtP7bpurBeX%Hjwr z4lI-2TdFpzkjgiv!8Vfv`=SP+s=^i3+N~1ELNWUbH|ytVu>EyPN_3(4TM^QE1swRo zoV7Y_g)a>28+hZG0e7g%@2^s>pzR4^fzR-El}ARTmtu!zjZLuX%>#OoU3}|rFjJg} zQ2TmaygxJ#sbHVyiA5KE+yH0LREWr%^C*yR|@gM$nK2P zo}M}PV0v))uJh&33N>#aU376@ZH79u(Yw`EQ2hM3SJs9f99+cO6_pNW$j$L-CtAfe zYfM)ccwD!P%LiBk!eCD?fHCGvgMQ%Q2oT_gmf?OY=A>&PaZQOq4eT=lwbaf}33LCH zFD|)lu{K7$8n9gX#w4~URjZxWm@wlH%oL#G|I~Fb-v^0L0TWu+`B+ZG!yII)w05DU z>GO?n(TN+B=>HdxVDSlIH76pta$_LhbBg;eZ`M7OGcqt||qi zogS72W1IN%=)5JCyOHWoFP7pOFK0L*OAh=i%&VW&4^LF@R;+K)t^S!96?}^+5QBIs zjJNTCh)?)4k^H^g1&jc>gysM`y^8Rm3qsvkr$9AeWwYpa$b22=yAd1t<*{ zaowSEFP+{y?Ob}8&cwfqoy4Pb9IA~VnM3u!trIK$&&0Op#Ql4j>(EW?UNUv#*iH1$ z^j>+W{afcd`{e&`-A{g}{JnIzYib)!T56IT@YEs{4|`sMpW3c8@UCoIJv`XsAw!XC z34|Il$LpW}CIHFC5e*)}00I5{%OL*WZRGzC0?_}-9{#ue?-ug^ zLE|uv-~6xnSs_2_&CN9{9vyc!Xgtn36_g^wI0C4s0s^;8+p?|mm;Odt3`2ZjwtK;l zfd6j)*Fr#53>C6Y8(N5?$H0ma;BCF3HCjUs7rpb2Kf*x3Xcj#O8mvs#&33i+McX zQpBxD8!O{5Y8D&0*QjD=Yhl9%M0)&_vk}bmN_Ud^BPN;H=U^bn&(csl-pkA+GyY0Z zKV7sU_4n;}uR78ouo8O%g*V;79KY?3d>k6%gpcmQsKk&@Vkw9yna_3asGt`0Hmj59 z%0yiF*`jXhByBI9QsD=+>big5{)BGe&+U2gAARGe3ID)xrid~QN_{I>k}@tzL!Md_ z&=7>TWciblF@EMC3t4-WX{?!m!G6$M$1S?NzF*2KHMP3Go4=#ZHkeIv{eEd;s-yD# z_jU^Ba06TZqvV|Yd;Z_sN%$X=!T+&?#p+OQIHS%!LO`Hx0q_Y0MyGYFNoM{W;&@0@ zLM^!X4KhdtsET5G<0+|q0oqVXMW~-7LW9Bg}=E$YtNh1#1D^6Mz(V9?2g~I1( zoz9Cz=8Hw98zVLwC2AQvp@pBeKyidn6Xu0-1SY1((^Hu*-!HxFUPs)yJ+i`^BC>PC zjwd0mygOVK#d2pRC9LxqGc6;Ui>f{YW9Bvb>33bp^NcnZoH~w9(lM5@JiIlfa-6|k ziy31UoMN%fvQfhi8^T+=yrP{QEyb-jK~>$A4SZT-N56NYEbpvO&yUme&pWKs3^94D zH{oXnUTb3T@H+RgzML*lejx`WAyw*?K7B-I(VJx($2!NXYm%3`=F~TbLv3H<{>D?A zJo-FDYdSA-(Y%;4KUP2SpHKAIcv9-ld(UEJE7=TKp|Gryn;72?0LHqAN^fk6%8PCW z{g_-t)G5uCIf0I`*F0ZNl)Z>))MaLMpXgqWgj-y;R+@A+AzDjsTqw2Mo9ULKA3c70 z!7SOkMtZb+MStH>9MnvNV0G;pwSW9HgP+`tg}e{ij0H6Zt5zJ7iw`hEnvye!XbA@!~#%vIkzowCOvq5I5@$3wtc*w2R$7!$*?}vg4;eDyJ_1=ixJuEp3pUS27W?qq(P^8$_lU!mRChT}ctvZz4p!X^ zOSp|JOAi~f?UkwH#9k{0smZ7-#=lK6X3OFEMl7%)WIcHb=#ZN$L=aD`#DZKOG4p4r zwlQ~XDZ`R-RbF&hZZhu3(67kggsM-F4Y_tI^PH8PMJRcs7NS9ogF+?bZB*fcpJ z=LTM4W=N9yepVvTj&Hu~0?*vR1HgtEvf8w%Q;U0^`2@e8{SwgX5d(cQ|1(!|i$km! zvY03MK}j`sff;*-%mN~ST>xU$6Bu?*Hm%l@0dk;j@%>}jsgDcQ)Hn*UfuThz9(ww_ zasV`rSrp_^bp-0sx>i35FzJwA!d6cZ5#5#nr@GcPEjNnFHIrtUYm1^Z$;{d&{hQV9 z6EfFHaIS}46p^5I-D_EcwwzUUuO}mqRh&T7r9sfw`)G^Q%oHxEs~+XoM?8e*{-&!7 z7$m$lg9t9KP9282eke608^Q2E%H-xm|oJ8=*SyEo} z@&;TQ3K)jgspgKHyGiKVMCz>xmC=H5Fy3!=TP)-R3|&1S-B)!6q50wfLHKM@7Bq6E z44CY%G;GY>tC`~yh!qv~YdXw! zSkquvYNs6k1r7>Eza?Vkkxo6XRS$W7EzL&A`o>=$HXgBp{L(i^$}t`NcnAxzbH8Ht z2!;`bhKIh`f1hIFcI5bHI=ueKdzmB9)!z$s-BT4ItyY|NaA_+o=jO%MU5as9 zc2)aLP>N%u>wlaXTK!p)r?+~)L+0eCGb5{8WIk7K52$nufnQ+m8YF+GQc&{^(zh-$ z#wyWV*Zh@d!b(WwXqvfhQX)^aoHTBkc;4ossV3&Ut*k>AI|m+{#kh4B!`3*<)EJVj zwrxK>99v^k4&Y&`Awm>|exo}NvewV%E+@vOc>5>%H#BK9uaE2$vje zWYM5fKuOTtn96B_2~~!xJPIcXF>E_;yO8AwpJ4)V`Hht#wbO3Ung~@c%%=FX4)q+9 z99#>VC2!4l`~0WHs9FI$Nz+abUq# zz`Of97})Su=^rGp2S$)7N3rQCj#0%2YO<R&p>$<#lgXcUj=4H_{oAYiT3 z44*xDn-$wEzRw7#@6aD)EGO$0{!C5Z^7#yl1o;k0PhN=aVUQu~eTQ^Xy{z8Ow6tk83 z4{5xe%(hx)%nD&|e*6sTWH`4W&U!Jae#U4TnICheJmsw{l|CH?UA{a6?2GNgpZLyzU2UlFu1ZVwlALmh_DOs03J^Cjh1im`E3?9&zvNmg(MuMw&0^Lu$(#CJ*q6DjlKsY-RMJ^8yIY|{SQZ*9~CH|u9L z`R78^r=EbbR*_>5?-)I+$6i}G)%mN(`!X72KaV(MNUP7Nv3MS9S|Pe!%N2AeOt5zG zVJ;jI4HZ$W->Ai_4X+`9c(~m=@ek*m`ZQbv3ryI-AD#AH=`x$~WeW~M{Js57(K7(v ze5`};LG|%C_tmd>bkufMWmAo&B+DT9ZV~h(4jg0>^aeAqL`PEUzJJtI8W1M!bQWpv zvN(d}E1@nlYa!L!!A*RN!(Q3F%J?5PvQ0udu?q-T)j3JKV~NL>KRb~w-lWc685uS6 z=S#aR&B8Sc8>cGJ!!--?kwsJTUUm`Jk?7`H z7PrO~xgBrSW2_tTlCq1LH8*!o?pj?qxy8}(=r_;G18POrFh#;buWR0qU24+XUaVZ0 z?(sXcr@-YqvkCmHr{U2oPogHL{r#3r49TeR<{SJX1pcUqyWPrkYz^X8#QW~?F)R5i z>p^!i<;qM8Nf{-fd6!_&V*e_9qP6q(s<--&1Ttj01j0w>bXY7y1W*%Auu&p|XSOH=)V7Bd4fUKh&T1)@cvqhuD-d=?w}O zjI%i(f|thk0Go*!d7D%0^ztBfE*V=(ZIN84f5HU}T9?ulmEYzT5usi=DeuI*d|;M~ zp_=Cx^!4k#=m_qSPBr5EK~E?3J{dWWPH&oCcNepYVqL?nh4D5ynfWip$m*YlZ8r^Z zuFEUL-nW!3qjRCLIWPT0x)FDL7>Yt7@8dA?R2kF@WE>ysMY+)lTsgNM#3VbXVGL}F z1O(>q>2a+_`6r5Xv$NZAnp=Kgnr3)cL(^=8ypEeOf3q8(HGe@7Tt59;yFl||w|mnO zHDxg2G3z8=(6wjj9kbcEY@Z0iOd7Gq5GiPS5% z*sF1J<#daxDV2Z8H>wxOF<;yKzMeTaSOp_|XkS9Sfn6Mpe9UBi1cSTieGG5$O;ZLIIJ60Y>SN4vC?=yE_CWlo(EEE$e4j?z&^FM%kNmRtlbEL^dPPgvs9sbK5fGw*r@ z+!EU@u$T8!nZh?Fdf_qk$VuHk^yVw`h`_#KoS*N%epIIOfQUy_&V}VWDGp3tplMbf z5Se1sJUC$7N0F1-9jdV2mmGK{-}fu|Nv;12jDy0<-kf^AmkDnu6j~TPWOgy1MT68|D z=4=50jVbUKdKaQgD`eWGr3I&^<6uhkjz$YwItY8%Yp9{z4-{6g{73<_b*@XJ4Nm3-3z z?BW3{aY_ccRjb@W1)i5nLg|7BnWS!B`_Uo9CWaE`Ij327QH?i)9A}4Ug4wmxVVa^b z-4+m%-wwOl7cKH7+=x&nrCrbEC)Q$fpg&V83#uEH;C=GNMz`ps@^RxK%T*8%OPnC` z{WO~J%nxYJ`x|N%?&i7?;{_8t^jM&=50HlaOQj8fS}_`moH$c;vI<|cruPFnpT8yU zS%rPOCUSd5Zdb(zwk`hqwTQn)*&n)uYsP*F_(~xEWq}C= zv30kFmZFwJZ@ELVX3?$dXQh|icO7UrL*_5G=I^xXjImz`ZPp>?g#tf(ej~KaIU0algsG!IS09;>?MvqGg#c{i+}qY|{P8W~O%#>|gFd z<1dr$-oxyRGN17yZo1OwLnzwYs0|;IS_nymNB0IlSzPQ%-r`?T=;_XQ^~&#}b|AB} zkNbN5uB?-sUB-T5QLlg%Uk3)uHB;>VIzGe9_J9 zaeISkQm!v(9d(0ML^b9fR^sfHFlH?7Mvddt37OuR{|O0{uv)(&-6<87W4 zyO>s!=cPgP3O&7xxU5DlIPw_o3O>6o6Qb?JWs3qw#p3sBc3g$?Dx zi(6D+DYgV;GrUis-CL%Qe{nvZnwaVXmbhH(|GFh|Q)k=1uvA$I@1DXI7bKlQ@8D6P zS?(*?><>)G49q0wr;NajpxP4W2G)kHl6^=Z>hrNEI4Mwd_$O6$1dXF;Q#hE(-eeW6 zz03GJF%Wl?HO=_ztv5*zRlcU~{+{k%#N59mgm~eK>P!QZ6E?#Cu^2)+K8m@ySvZ*5 z|HDT}BkF@3!l(0%75G=1u2hETXEj!^1Z$!)!lyGXlWD!_vqGE$Z)#cUVBqlORW>0^ zDjyVTxwKHKG|0}j-`;!R-p>}qQfBl(?($7pP<+Y8QE#M8SCDq~k<+>Q^Zf@cT_WdX3~BSe z+|KK|7OL5Hm5(NFP~j>Ct3*$wi0n0!xl=(C61`q&cec@mFlH(sy%+RH<=s)8aAPN`SfJdkAQjdv82G5iRdv8 zh{9wHUZaniSEpslXl^_ODh}mypC?b*9FzLjb~H@3DFSe;D(A-K3t3eOTB(m~I6C;(-lKAvit(70k`%@+O*Ztdz;}|_TS~B?Tpmi=QKC^m_ z2YpEaT3iiz*;T~ap1yiA)a`dKMwu`^UhIUeltNQ1Yjo=q@bI@&3zH?rVUg=IxLy-ni zyxDu%-Fr{H6owTjZU2O5>nDb=q&Jz_TjeSq%!2m40x&U6w~GQ({quPL73IsJS;f`$ zsuhioqCBj(gJ>2hoo)Gou7(WP*pX)f=Y=!=k!&1K?EYY%jJ~X&DnK{^saPQK<1BJ z_A`_{%ZozcB(3w$z^To^6d|XuT@=X~wtW!+{4ID@N{AB~J6AL5vuY>JwvWCNFKsKh zd}@>q@_WV#QZ&UJ0#?X(pXR!oyXOEG3rqzHbCzGLONDb042i$})fM@XF)uSP(DHUc z^&{|$*xe{cs?Gp8=B%RY3L7#$ve$?TWh>MZdxF1zH1v}1z+$Ov#G7?%D)bBCyDe*% zSeKSpETC2V1){II>@UwJi>4uBN+iAx+82E~gb|Cr&8E^i&)A!uv-g?jzH99wU}8+# z$nh>yvb;TwZmS@7LrvuCu_d0-WxFNI&C7%sWuTL%YU!l|I1{|->=dlOeHOCtUO#zkS3ESO8LHV4hTdQL5EdV zuWD33fFPH}HPrW^s$Qn1Xgp&AT6<-He{{4%eIu3rN=iK|9mURdKXfB&Q?qGok%!cs ze53UP{Z!TO-Y@q2;;k2avA3`lm4OoN4@S*k=UA)7H;qZ`d8`XaYFCv?Ba+uGW@r5v z&&{nf(24WSBOhc7!qF^@0cz;XcUynNaj6w2349;s!K{KVqs5yS{ z7VubS`2OzT^5#1~6Tt^RTvt9-J|D2F>y~>2;jeF>g`hx5l%B3H=aLExQihuYngzlnBTYOTHJQMzl>kwqN5JYs)Ej zblA@ntkUS~xi+}y6|(81helS}Q~&VB37qyV|S3Y=><^1wh%msQM?fz z<58MX(=|PSUKCF#)dbhR%D&xgCD?$aR0qen+wpp6 zst}vX18!Be96TD??j1HsHTUx(a&@F?=gT`Q$oJFFyrh^;zgz!(NlAHGn0cJy@us=w zNhC#l5G;H}+>49Nsh12=ZPO2r*2OBQe5kpb&1?*PIBFitK8}FUfb~S-#hKfF0o#&d z#3aPkB$9scYku&kA6{0xHnBV#&Wei5J>5T-XX-gUXEPo+9b7WL=*XESc(3BshL`aj zXp}QIp*40}oWJt*l043e8_5;H5PI5c)U&IEw5dF(4zjX0y_lk9 zAp@!mK>WUqHo)-jop=DoK>&no>kAD=^qIE7qis&_*4~ z6q^EF$D@R~3_xseCG>Ikb6Gfofb$g|75PPyyZN&tiRxqovo_k zO|HA|sgy#B<32gyU9x^&)H$1jvw@qp+1b(eGAb)O%O!&pyX@^nQd^9BQ4{(F8<}|A zhF&)xusQhtoXOOhic=8#Xtt5&slLia3c*a?dIeczyTbC#>FTfiLST57nc3@Y#v_Eg#VUv zT8cKH#f3=1PNj!Oroz_MAR*pow%Y0*6YCYmUy^7`^r|j23Q~^*TW#cU7CHf0eAD_0 zEWEVddxFgQ7=!nEBQ|ibaScslvhuUk^*%b#QUNrEB{3PG@uTxNwW}Bs4$nS9wc(~O zG7Iq>aMsYkcr!9#A;HNsJrwTDYkK8ikdj{M;N$sN6BqJ<8~z>T20{J8Z2rRUuH7~3 z=tgS`AgxbBOMg87UT4Lwge`*Y=01Dvk>)^{Iu+n6fuVX4%}>?3czOGR$0 zpp*wp>bsFFSV`V;r_m+TZns$ZprIi`OUMhe^cLE$2O+pP3nP!YB$ry}2THx2QJs3< za1;>d-AggCarrQ>&Z!d@;mW+!q6eXhb&`GbzUDSxpl8AJ#Cm#tuc)_xh(2NV=5XMs zrf_ozRYO$NkC=pKFX5OH8v1>0i9Z$ec`~Mf+_jQ68spn(CJwclDhEEkH2Qw;${J$clv__nUjn5jA0wCLEnu1j;v!0vB>Ri6m9`;R{JMS%^)4FC zU0Z44+u$I$w=Bj|iu4DT5h~sS`C*zbmX?@-crY}E+hy>}2~C0Nn(EKk@5^qO4@l@! z6O0lr%tzGC`D^)8xU3FnMZVm0kX1sBWhaQyzVoXFWwr%Ny?=2M{5s#5i7fTu3gEkG zc{(Pr$v=;`Y#&`y*J}#M9ux>0?xu!`$9cUKm#Bdd_&S#LPTS?ZPV6zN6>W6JTS~-LfjL{mB=b(KMk3 z2HjBSlJeyUVqDd=Mt!=hpYsvby2GL&3~zm;0{^nZJq+4vb?5HH4wufvr}IX42sHeK zm@x?HN$8TsTavXs)tLDFJtY9b)y~Tl@7z4^I8oUQq4JckH@~CVQ;FoK(+e0XAM>1O z(ei}h?)JQp>)d=6ng-BZF1Z5hsAKW@mXq+hU?r8I(*%`tnIIOXw7V6ZK(T9RFJJe@ zZS!aC+p)Gf2Ujc=a6hx4!A1Th%YH!Lb^xpI!Eu` zmJO{9rw){B1Ql18d%F%da+Tbu1()?o(zT7StYqK6_w`e+fjXq5L^y(0 z09QA6H4oFj59c2wR~{~>jUoDzDdKz}5#onYPJRwa`SUO)Pd4)?(ENBaFVLJr6Kvz= zhTtXqbx09C1z~~iZt;g^9_2nCZ{};-b4dQJbv8HsWHXPVg^@(*!@xycp#R?a|L!+` zY5w))JWV`Gls(=}shH0#r*;~>_+-P5Qc978+QUd>J%`fyn{*TsiG-dWMiJXNgwBaT zJ=wgYFt+1ACW)XwtNx)Q9tA2LPoB&DkL16P)ERWQlY4%Y`-5aM9mZ{eKPUgI!~J3Z zkMd5A_p&v?V-o-6TUa8BndiX?ooviev(DKw=*bBVOW|=zps9=Yl|-R5@yJe*BPzN}a0mUsLn{4LfjB_oxpv(mwq# zSY*%E{iB)sNvWfzg-B!R!|+x(Q|b@>{-~cFvdDHA{F2sFGA5QGiIWy#3?P2JIpPKg6ncI^)dvqe`_|N=8DQ>P;GjjD{w zH}lENr;dU&FbEU?00aa80D$0M0RRB{U*7-#kbjS|qAG&4l5%47zyJ#WrfA#1$1Ctx zf&Z_d{GW=lf^w2#qRJ|CvSJUi(^E3iv~=^Z(zH}F)3Z%V3`@+rNB7gTVU{Bb~90p|f+0(v;nz01EG7yDMX9@S~__vVgv%rS$+?IH+oZ03D5zYrv|^ zC1J)SruYHmCki$jLBlTaE5&dFG9-kq3!^i>^UQL`%gn6)jz54$WDmeYdsBE9;PqZ_ zoGd=P4+|(-u4U1dbAVQrFWoNgNd;0nrghPFbQrJctO>nwDdI`Q^i0XJDUYm|T|RWc zZ3^Qgo_Qk$%Fvjj-G}1NB#ZJqIkh;kX%V{THPqOyiq)d)0+(r9o(qKlSp*hmK#iIY zA^)Vr$-Hz<#SF=0@tL@;dCQsm`V9s1vYNq}K1B)!XSK?=I1)tX+bUV52$YQu*0%fnWEukW>mxkz+%3-S!oguE8u#MGzST8_Dy^#U?fA@S#K$S@9msUiX!gd_ow>08w5)nX{-KxqMOo7d?k2&?Vf z&diGDtZr(0cwPe9z9FAUSD9KC)7(n^lMWuayCfxzy8EZsns%OEblHFSzP=cL6}?J| z0U$H!4S_TVjj<`6dy^2j`V`)mC;cB%* z8{>_%E1^FH!*{>4a7*C1v>~1*@TMcLK{7nEQ!_igZC}ikJ$*<$yHy>7)oy79A~#xE zWavoJOIOC$5b6*q*F_qN1>2#MY)AXVyr$6x4b=$x^*aqF*L?vmj>Mgv+|ITnw_BoW zO?jwHvNy^prH{9$rrik1#fhyU^MpFqF2fYEt(;4`Q&XWOGDH8k6M=%@fics4ajI;st# zCU^r1CK&|jzUhRMv;+W~6N;u<;#DI6cCw-otsc@IsN3MoSD^O`eNflIoR~l4*&-%RBYk@gb^|-JXs&~KuSEmMxB}xSb z@K76cXD=Y|=I&SNC2E+>Zg?R6E%DGCH5J1nU!A|@eX9oS(WPaMm==k2s_ueCqdZw| z&hqHp)47`c{BgwgvY2{xz%OIkY1xDwkw!<0veB#yF4ZKJyabhyyVS`gZepcFIk%e2 zTcrmt2@-8`7i-@5Nz>oQWFuMC_KlroCl(PLSodswHqJ3fn<;gxg9=}~3x_L3P`9Sn zChIf}8vCHvTriz~T2~FamRi?rh?>3bX1j}%bLH+uFX+p&+^aXbOK7clZxdU~6Uxgy z8R=obwO4dL%pmVo*Ktf=lH6hnlz_5k3cG;m8lgaPp~?eD!Yn2kf)tU6PF{kLyn|oI@eQ`F z3IF7~Blqg8-uwUuWZScRKn%c2_}dXB6Dx_&xR*n9M9LXasJhtZdr$vBY!rP{c@=)& z#!?L$2UrkvClwQO>U*fSMs67oSj2mxiJ$t;E|>q%Kh_GzzWWO&3;ufU%2z%ucBU8H z3WIwr$n)cfCXR&>tyB7BcSInK>=ByZA%;cVEJhcg<#6N{aZC4>K41XF>ZgjG`z_u& zGY?;Ad?-sgiOnI`oppF1o1Gurqbi*;#x2>+SSV6|1^G@ooVy@fg?wyf@0Y!UZ4!}nGuLeC^l)6pwkh|oRY`s1Pm$>zZ3u-83T|9 zGaKJIV3_x+u1>cRibsaJpJqhcm%?0-L;2 zitBrdRxNmb0OO2J%Y&Ym(6*`_P3&&5Bw157{o7LFguvxC$4&zTy#U=W*l&(Q2MNO} zfaUwYm{XtILD$3864IA_nn34oVa_g^FRuHL5wdUd)+W-p-iWCKe8m_cMHk+=? zeKX)M?Dt(|{r5t7IenkAXo%&EXIb-i^w+0CX0D=xApC=|Xy(`xy+QG^UyFe z+#J6h_&T5i#sV)hj3D4WN%z;2+jJcZxcI3*CHXGmOF3^)JD5j&wfX)e?-|V0GPuA+ zQFot%aEqGNJJHn$!_}#PaAvQ^{3-Ye7b}rWwrUmX53(|~i0v{}G_sI9uDch_brX&6 zWl5Ndj-AYg(W9CGfQf<6!YmY>Ey)+uYd_JNXH=>|`OH-CDCmcH(0%iD_aLlNHKH z7bcW-^5+QV$jK?R*)wZ>r9t}loM@XN&M-Pw=F#xn(;u3!(3SXXY^@=aoj70;_=QE9 zGghsG3ekq#N||u{4We_25U=y#T*S{4I{++Ku)> zQ!DZW;pVcn>b;&g2;YE#+V`v*Bl&Y-i@X6D*OpNA{G@JAXho&aOk(_j^weW{#3X5Y z%$q_wpb07EYPdmyH(1^09i$ca{O<}7) zRWncXdSPgBE%BM#by!E>tdnc$8RwUJg1*x($6$}ae$e9Knj8gvVZe#bLi!<+&BkFj zg@nOpDneyc+hU9P-;jmOSMN|*H#>^Ez#?;%C3hg_65leSUm;iz)UkW)jX#p)e&S&M z1|a?wDzV5NVnlhRBCd_;F87wp>6c<&nkgvC+!@KGiIqWY4l}=&1w7|r6{oBN8xyzh zG$b#2=RJp_iq6)#t5%yLkKx(0@D=C3w+oiXtSuaQ%I1WIb-eiE$d~!)b@|4XLy!CZ z9p=t=%3ad@Ep+<9003D2KZ5VyP~_n$=;~r&YUg5UZ0KVD&tR1DHy9x)qWtKJp#Kq# zP*8p#W(8JJ_*h_3W}FlvRam?<4Z+-H77^$Lvi+#vmhL9J zJ<1SV45xi;SrO2f=-OB(7#iNA5)x1uNC-yNxUw|!00vcW2PufRm>e~toH;M0Q85MQLWd?3O{i8H+5VkR@l9Dg-ma ze2fZ%>G(u5(k9EHj2L6!;(KZ8%8|*-1V|B#EagbF(rc+5iL_5;Eu)L4Z-V;0HfK4d z*{utLse_rvHZeQ>V5H=f78M3Ntg1BPxFCVD{HbNA6?9*^YIq;B-DJd{Ca2L#)qWP? zvX^NhFmX?CTWw&Ns}lgs;r3i+Bq@y}Ul+U%pzOS0Fcv9~aB(0!>GT0)NO?p=25LjN z2bh>6RhgqD7bQj#k-KOm@JLgMa6>%-ok1WpOe)FS^XOU{c?d5shG(lIn3GiVBxmg`u%-j=)^v&pX1JecJics3&jvPI)mDut52? z3jEA)DM%}BYbxxKrizVYwq?(P&19EXlwD9^-6J+4!}9{ywR9Gk42jjAURAF&EO|~N z)?s>$Da@ikI4|^z0e{r`J8zIs>SpM~Vn^{3fArRu;?+43>lD+^XtUcY1HidJwnR6+ z!;oG2=B6Z_=M%*{z-RaHc(n|1RTKQdNjjV!Pn9lFt^4w|AeN06*j}ZyhqZ^!-=cyGP_ShV1rGxkx8t zB;8`h!S{LD%ot``700d0@Grql(DTt4Awgmi+Yr0@#jbe=2#UkK%rv=OLqF)9D7D1j z!~McAwMYkeaL$~kI~90)5vBhBzWYc3Cj1WI0RS`z000R8-@ET0dA~*r(gSiCJmQMN&4%1D zyVNf0?}sBH8zNbBLn>~(W{d3%@kL_eQ6jEcR{l>C|JK z(R-fA!z|TTRG40|zv}7E@PqCAXP3n`;%|SCQ|ZS%ym$I{`}t3KPL&^l5`3>yah4*6 zifO#{VNz3)?ZL$be;NEaAk9b#{tV?V7 zP|wf5YA*1;s<)9A4~l3BHzG&HH`1xNr#%){4xZ!jq%o=7nN*wMuXlFV{HaiQLJ`5G zBhDi#D(m`Q1pLh@Tq+L;OwuC52RdW7b8}~60WCOK5iYMUad9}7aWBuILb({5=z~YF zt?*Jr5NG+WadM{mDL>GyiByCuR)hd zA=HM?J6l1Xv0Dl+LW@w$OTcEoOda^nFCw*Sy^I@$sSuneMl{4ys)|RY#9&NxW4S)9 zq|%83IpslTLoz~&vTo!Ga@?rj_kw{|k{nv+w&Ku?fyk4Ki4I?);M|5Axm)t+BaE)D zm(`AQ#k^DWrjbuXoJf2{Aj^KT zFb1zMSqxq|vceV+Mf-)$oPflsO$@*A0n0Z!R{&(xh8s}=;t(lIy zv$S8x>m;vQNHuRzoaOo?eiWFe{0;$s`Bc+Osz~}Van${u;g(su`3lJ^TEfo~nERfP z)?aFzpDgnLYiERsKPu|0tq4l2wT)Atr6Qb%m-AUn6HnCue*yWICp7TjW$@sO zm5rm4aTcPQ(rfi7a`xP7cKCFrJD}*&_~xgLyr^-bmsL}y;A5P|al8J3WUoBSjqu%v zxC;mK!g(7r6RRJ852Z~feoC&sD3(6}^5-uLK8o)9{8L_%%rItZK9C){UxB|;G>JbP zsRRtS4-3B*5c+K2kvmgZK8472%l>3cntWUOVHxB|{Ay~aOg5RN;{PJgeVD*H%ac+y!h#wi%o2bF2Ca8IyMyH{>4#{E_8u^@+l-+n=V}Sq?$O z{091@v%Bd*3pk0^2UtiF9Z+(a@wy6 zUdw8J*ze$K#=$48IBi1U%;hmhO>lu!uU;+RS}p&6@rQila7WftH->*A4=5W|Fmtze z)7E}jh@cbmr9iup^i%*(uF%LG&!+Fyl@LFA-}Ca#bxRfDJAiR2dt6644TaYw1Ma79 zt8&DYj31j^5WPNf5P&{)J?WlCe@<3u^78wnd(Ja4^a>{^Tw}W>|Cjt^If|7l^l)^Q zbz|7~CF(k_9~n|h;ysZ+jHzkXf(*O*@5m zLzUmbHp=x!Q|!9NVXyipZ3)^GuIG$k;D)EK!a5=8MFLI_lpf`HPKl=-Ww%z8H_0$j ztJ||IfFG1lE9nmQ0+jPQy zCBdKkjArH@K7jVcMNz);Q(Q^R{d5G?-kk;Uu_IXSyWB)~KGIizZL(^&qF;|1PI7!E zTP`%l)gpX|OFn&)M%txpQ2F!hdA~hX1Cm5)IrdljqzRg!f{mN%G~H1&oqe`5eJCIF zHdD7O;AX-{XEV(a`gBFJ9ews#CVS2y!&>Cm_dm3C8*n3MA*e67(WC?uP@8TXuMroq z{#w$%z@CBIkRM7?}Xib+>hRjy?%G!fiw8! z8(gB+8J~KOU}yO7UGm&1g_MDJ$IXS!`+*b*QW2x)9>K~Y*E&bYMnjl6h!{17_8d!%&9D`a7r&LKZjC<&XOvTRaKJ1 zUY@hl5^R&kZl3lU3njk`3dPzxj$2foOL26r(9zsVF3n_F#v)s5vv3@dgs|lP#eylq62{<-vczqP!RpVBTgI>@O6&sU>W|do17+#OzQ7o5A$ICH z?GqwqnK^n2%LR;$^oZM;)+>$X3s2n}2jZ7CdWIW0lnGK-b#EG01)P@aU`pg}th&J-TrU`tIpb5t((0eu|!u zQz+3ZiOQ^?RxxK4;zs=l8q!-n7X{@jSwK(iqNFiRColuEOg}!7cyZi`iBX4g1pNBj zAPzL?P^Ljhn;1$r8?bc=#n|Ed7wB&oHcw()&*k#SS#h}jO?ZB246EGItsz*;^&tzp zu^YJ0=lwsi`eP_pU8}6JA7MS;9pfD;DsSsLo~ogzMNP70@@;Fm8f0^;>$Z>~}GWRw!W5J3tNX*^2+1f3hz{~rIzJo z6W%J(H!g-eI_J1>0juX$X4Cl6i+3wbc~k146UIX&G22}WE>0ga#WLsn9tY(&29zBvH1$`iWtTe zG2jYl@P!P)eb<5DsR72BdI7-zP&cZNI{7q3e@?N8IKc4DE#UVr->|-ryuJXk^u^>4 z$3wE~=q390;XuOQP~TNoDR?#|NSPJ%sTMInA6*rJ%go|=YjGe!B>z6u$IhgQSwoV* zjy3F2#I>uK{42{&IqP59)Y(1*Z>>#W8rCf4_eVsH)`v!P#^;BgzKDR`ARGEZzkNX+ zJUQu=*-ol=Xqqt5=`=pA@BIn@6a9G8C{c&`i^(i+BxQO9?YZ3iu%$$da&Kb?2kCCo zo7t$UpSFWqmydXf@l3bVJ=%K?SSw)|?srhJ-1ZdFu*5QhL$~-IQS!K1s@XzAtv6*Y zl8@(5BlWYLt1yAWy?rMD&bwze8bC3-GfNH=p zynNFCdxyX?K&G(ZZ)afguQ2|r;XoV^=^(;Cku#qYn4Lus`UeKt6rAlFo_rU`|Rq z&G?~iWMBio<78of-2X(ZYHx~=U0Vz4btyXkctMKdc9UM!vYr~B-(>)(Hc|D zMzkN4!PBg%tZoh+=Gba!0++d193gbMk2&krfDgcbx0jI92cq?FFESVg0D$>F+bil} zY~$)|>1HZsX=5sAZ2WgPB5P=8X#TI+NQ(M~GqyVB53c6IdX=k>Wu@A0Svf5#?uHaF zsYn|koIi3$(%GZ2+G+7Fv^lHTb#5b8sAHSTnL^qWZLM<(1|9|QFw9pnRU{svj}_Al zL)b9>fN{QiA($8peNEJyy`(a{&uh-T4_kdZFIVsKKVM(?05}76EEz?#W za^fiZOAd14IJ4zLX-n7Lq0qlQ^lW8Cvz4UKkV9~P}>sq0?xD3vg+$4vLm~C(+ zM{-3Z#qnZ09bJ>}j?6ry^h+@PfaD7*jZxBEY4)UG&daWb??6)TP+|3#Z&?GL?1i+280CFsE|vIXQbm| zM}Pk!U`U5NsNbyKzkrul-DzwB{X?n3E6?TUHr{M&+R*2%yOiXdW-_2Yd6?38M9Vy^ z*lE%gA{wwoSR~vN0=no}tP2Ul5Gk5M(Xq`$nw#ndFk`tcpd5A=Idue`XZ!FS>Q zG^0w#>P4pPG+*NC9gLP4x2m=cKP}YuS!l^?sHSFftZy{4CoQrb_ z^20(NnG`wAhMI=eq)SsIE~&Gp9Ne0nD4%Xiu|0Fj1UFk?6avDqjdXz{O1nKao*46y zT8~iA%Exu=G#{x=KD;_C&M+Zx4+n`sHT>^>=-1YM;H<72k>$py1?F3#T1*ef9mLZw z5naLQr?n7K;2l+{_uIw*_1nsTn~I|kkCgrn;|G~##hM;9l7Jy$yJfmk+&}W@JeKcF zx@@Woiz8qdi|D%aH3XTx5*wDlbs?dC1_nrFpm^QbG@wM=i2?Zg;$VK!c^Dp8<}BTI zyRhAq@#%2pGV49*Y5_mV4+OICP|%I(dQ7x=6Ob}>EjnB_-_18*xrY?b%-yEDT(wrO z9RY2QT0`_OpGfMObKHV;QLVnrK%mc?$WAdIT`kJQT^n%GuzE7|9@k3ci5fYOh(287 zuIbg!GB3xLg$YN=n)^pHGB0jH+_iIiC=nUcD;G6LuJsjn2VI1cyZx=a?ShCsF==QK z;q~*m&}L<-cb+mDDXzvvrRsybcgQ;Vg21P(uLv5I+eGc7o7tc6`;OA9{soHFOz zT~2?>Ts}gprIX$wRBb4yE>ot<8+*Bv`qbSDv*VtRi|cyWS>)Fjs>fkNOH-+PX&4(~ z&)T8Zam2L6puQl?;5zg9h<}k4#|yH9czHw;1jw-pwBM*O2hUR6yvHATrI%^mvs9q_ z&ccT0>f#eDG<^WG^q@oVqlJrhxH)dcq2cty@l3~|5#UDdExyXUmLQ}f4#;6fI{f^t zDCsgIJ~0`af%YR%Ma5VQq-p21k`vaBu6WE?66+5=XUd%Ay%D$irN>5LhluRWt7 zov-=f>QbMk*G##&DTQyou$s7UqjjW@k6=!I@!k+S{pP8R(2=e@io;N8E`EOB;OGoI zw6Q+{X1_I{OO0HPpBz!X!@`5YQ2)t{+!?M_iH25X(d~-Zx~cXnS9z>u?+If|iNJbx zyFU2d1!ITX64D|lE0Z{dLRqL1Ajj=CCMfC4lD3&mYR_R_VZ>_7_~|<^o*%_&jevU+ zQ4|qzci=0}Jydw|LXLCrOl1_P6Xf@c0$ieK2^7@A9UbF{@V_0p%lqW|L?5k>bVM8|p5v&2g;~r>B8uo<4N+`B zH{J)h;SYiIVx@#jI&p-v3dwL5QNV1oxPr8J%ooezTnLW>i*3Isb49%5i!&ac_dEXv zvXmVUck^QHmyrF8>CGXijC_R-y(Qr{3Zt~EmW)-nC!tiH`wlw5D*W7Pip;T?&j%kX z6DkZX4&}iw>hE(boLyjOoupf6JpvBG8}jIh!!VhnD0>}KSMMo{1#uU6kiFcA04~|7 zVO8eI&x1`g4CZ<2cYUI(n#wz2MtVFHx47yE5eL~8bot~>EHbevSt}LLMQX?odD{Ux zJMnam{d)W4da{l7&y-JrgiU~qY3$~}_F#G7|MxT)e;G{U`In&?`j<5D->}cb{}{T(4DF0BOk-=1195KB-E*o@c?`>y#4=dMtYtSY=&L{!TAjFVcq0y@AH`vH! z$41+u!Ld&}F^COPgL(EE{0X7LY&%D7-(?!kjFF7=qw<;`V{nwWBq<)1QiGJgUc^Vz ztMUlq1bZqKn17|6x6iAHbWc~l1HcmAxr%$Puv!znW)!JiukwIrqQ00|H$Z)OmGG@= zv%A8*4cq}(?qn4rN6o`$Y))(MyXr8R<2S^J+v(wmFmtac!%VOfN?&(8Nr!T@kV`N; z*Q33V3t`^rN&aBiHet)18wy{*wi1=W!B%B-Q6}SCrUl$~Hl{@!95ydml@FK8P=u4s z4e*7gV2s=YxEvskw2Ju!2%{8h01rx-3`NCPc(O zH&J0VH5etNB2KY6k4R@2Wvl^Ck$MoR3=)|SEclT2ccJ!RI9Nuter7u9@;sWf-%um;GfI!=eEIQ2l2p_YWUd{|6EG ze{yO6;lMc>;2tPrsNdi@&1K6(1;|$xe8vLgiouj%QD%gYk`4p{Ktv9|j+!OF-P?@p z;}SV|oIK)iwlBs+`ROXkhd&NK zzo__r!B>tOXpBJMDcv!Mq54P+n4(@dijL^EpO1wdg~q+!DT3lB<>9AANSe!T1XgC=J^)IP0XEZ()_vpu!!3HQyJhwh?r`Ae%Yr~b% zO*NY9t9#qWa@GCPYOF9aron7thfWT`eujS4`t2uG6)~JRTI;f(ZuoRQwjZjp5Pg34 z)rp$)Kr?R+KdJ;IO;pM{$6|2y=k_siqvp%)2||cHTe|b5Ht8&A{wazGNca zX$Ol?H)E_R@SDi~4{d-|8nGFhZPW;Cts1;08TwUvLLv&_2$O6Vt=M)X;g%HUr$&06 zISZb(6)Q3%?;3r~*3~USIg=HcJhFtHhIV(siOwV&QkQe#J%H9&E21!C*d@ln3E@J* zVqRO^<)V^ky-R|%{(9`l-(JXq9J)1r$`uQ8a}$vr9E^nNiI*thK8=&UZ0dsFN_eSl z(q~lnD?EymWLsNa3|1{CRPW60>DSkY9YQ;$4o3W7Ms&@&lv9eH!tk~N&dhqX&>K@} zi1g~GqglxkZ5pEFkllJ)Ta1I^c&Bt6#r(QLQ02yHTaJB~- zCcE=5tmi`UA>@P=1LBfBiqk)HB4t8D?02;9eXj~kVPwv?m{5&!&TFYhu>3=_ zsGmYZ^mo*-j69-42y&Jj0cBLLEulNRZ9vXE)8~mt9C#;tZs;=#M=1*hebkS;7(aGf zcs7zH(I8Eui9UU4L--))yy`&d&$In&VA2?DAEss4LAPCLd>-$i?lpXvn!gu^JJ$(DoUlc6wE98VLZ*z`QGQov5l4Fm_h?V-;mHLYDVOwKz7>e4+%AzeO>P6v}ndPW| zM>m#6Tnp7K?0mbK=>gV}=@k*0Mr_PVAgGMu$j+pWxzq4MAa&jpCDU&-5eH27Iz>m^ zax1?*HhG%pJ((tkR(V(O(L%7v7L%!_X->IjS3H5kuXQT2!ow(;%FDE>16&3r){!ex zhf==oJ!}YU89C9@mfDq!P3S4yx$aGB?rbtVH?sHpg?J5C->!_FHM%Hl3#D4eplxzQ zRA+<@LD%LKSkTk2NyWCg7u=$%F#;SIL44~S_OGR}JqX}X+=bc@swpiClB`Zbz|f!4 z7Ysah7OkR8liXfI`}IIwtEoL}(URrGe;IM8%{>b1SsqXh)~w}P>yiFRaE>}rEnNkT z!HXZUtxUp1NmFm)Dm@-{FI^aRQqpSkz}ZSyKR%Y}YHNzBk)ZIp} zMtS=aMvkgWKm9&oTcU0?S|L~CDqA+sHpOxwnswF-fEG)cXCzUR?ps@tZa$=O)=L+5 zf%m58cq8g_o}3?Bhh+c!w4(7AjxwQ3>WnVi<{{38g7yFboo>q|+7qs<$8CPXUFAN< zG&}BHbbyQ5n|qqSr?U~GY{@GJ{(Jny{bMaOG{|IkUj7tj^9pa9|FB_<+KHLxSxR;@ zHpS$4V)PP+tx}22fWx(Ku9y+}Ap;VZqD0AZW4gCDTPCG=zgJmF{|x;(rvdM|2|9a}cex6xrMkERnkE;}jvU-kmzd%_J50$M`lIPCKf+^*zL=@LW`1SaEc%=m zQ+lT06Gw+wVwvQ9fZ~#qd430v2HndFsBa9WjD0P}K(rZYdAt^5WQIvb%D^Q|pkVE^ zte$&#~zmULFACGfS#g=2OLOnIf2Of-k!(BIHjs77nr!5Q1*I9 z1%?=~#Oss!rV~?-6Gm~BWJiA4mJ5TY&iPm_$)H1_rTltuU1F3I(qTQ^U$S>%$l z)Wx1}R?ij0idp@8w-p!Oz{&*W;v*IA;JFHA9%nUvVDy7Q8woheC#|8QuDZb-L_5@R zOqHwrh|mVL9b=+$nJxM`3eE{O$sCt$UK^2@L$R(r^-_+z?lOo+me-VW=Zw z-Bn>$4ovfWd%SPY`ab-u9{INc*k2h+yH%toDHIyqQ zO68=u`N}RIIs7lsn1D){)~%>ByF<>i@qFb<-axvu(Z+6t7v<^z&gm9McRB~BIaDn$ z#xSGT!rzgad8o>~kyj#h1?7g96tOcCJniQ+*#=b7wPio>|6a1Z?_(TS{)KrPe}(8j z!#&A=k(&Pj^F;r)CI=Z{LVu>uj!_W1q4b`N1}E(i%;BWjbEcnD=mv$FL$l?zS6bW!{$7j1GR5ocn94P2u{ z70tAAcpqtQo<@cXw~@i-@6B23;317|l~S>CB?hR5qJ%J3EFgyBdJd^fHZu7AzHF(BQ!tyAz^L0`X z23S4Fe{2X$W0$zu9gm%rg~A>ijaE#GlYlrF9$ds^QtaszE#4M(OLVP2O-;XdT(XIC zatwzF*)1c+t~c{L=fMG8Z=k5lv>U0;C{caN1NItnuSMp)6G3mbahu>E#sj&oy94KC zpH}8oEw{G@N3pvHhp{^-YaZeH;K+T_1AUv;IKD<=mv^&Ueegrb!yf`4VlRl$M?wsl zZyFol(2|_QM`e_2lYSABpKR{{NlxlDSYQNkS;J66aT#MSiTx~;tUmvs-b*CrR4w=f z8+0;*th6kfZ3|5!Icx3RV11sp=?`0Jy3Fs0N4GZQMN=8HmT6%x9@{Dza)k}UwL6JT zHRDh;%!XwXr6yuuy`4;Xsn0zlR$k%r%9abS1;_v?`HX_hI|+EibVnlyE@3aL5vhQq zlIG?tN^w@0(v9M*&L+{_+RQZw=o|&BRPGB>e5=ys7H`nc8nx)|-g;s7mRc7hg{GJC zAe^vCIJhajmm7C6g! zL&!WAQ~5d_5)00?w_*|*H>3$loHrvFbitw#WvLB!JASO?#5Ig5$Ys10n>e4|3d;tS zELJ0|R4n3Az(Fl3-r^QiV_C;)lQ1_CW{5bKS15U|E9?ZgLec@%kXr84>5jV2a5v=w z?pB1GPdxD$IQL4)G||B_lI+A=08MUFFR4MxfGOu07vfIm+j=z9tp~5i_6jb`tR>qV z$#`=BQ*jpCjm$F0+F)L%xRlnS%#&gro6PiRfu^l!EVan|r3y}AHJQOORGx4~ z&<)3=K-tx518DZyp%|!EqpU!+X3Et7n2AaC5(AtrkW>_57i}$eqs$rupubg0a1+WO zGHZKLN2L0D;ab%{_S1Plm|hx8R?O14*w*f&2&bB050n!R2by zw!@XOQx$SqZ5I<(Qu$V6g>o#A!JVwErWv#(Pjx=KeS0@hxr4?13zj#oWwPS(7Ro|v z>Mp@Kmxo79q|}!5qtX2-O@U&&@6s~!I&)1WQIl?lTnh6UdKT_1R640S4~f=_xoN3- zI+O)$R@RjV$F=>Ti7BlnG1-cFKCC(t|Qjm{SalS~V-tX#+2ekRhwmN zZr`8{QF6y~Z!D|{=1*2D-JUa<(1Z=;!Ei!KiRNH?o{p5o3crFF=_pX9O-YyJchr$~ zRC`+G+8kx~fD2k*ZIiiIGR<8r&M@3H?%JVOfE>)})7ScOd&?OjgAGT@WVNSCZ8N(p zuQG~76GE3%(%h1*vUXg$vH{ua0b`sQ4f0*y=u~lgyb^!#CcPJa2mkSEHGLsnO^kb$ zru5_l#nu=Y{rSMWiYx?nO{8I!gH+?wEj~UM?IrG}E|bRIBUM>UlY<`T1EHpRr36vv zBi&dG8oxS|J$!zoaq{+JpJy+O^W(nt*|#g32bd&K^w-t>!Vu9N!k9eA8r!Xc{utY> zg9aZ(D2E0gL#W0MdjwES-7~Wa8iubPrd?8-$C4BP?*wok&O8+ykOx{P=Izx+G~hM8 z*9?BYz!T8~dzcZr#ux8kS7u7r@A#DogBH8km8Ry4slyie^n|GrTbO|cLhpqgMdsjX zJ_LdmM#I&4LqqsOUIXK8gW;V0B(7^$y#h3h>J0k^WJfAMeYek%Y-Dcb_+0zPJez!GM zAmJ1u;*rK=FNM0Nf}Y!!P9c4)HIkMnq^b;JFd!S3?_Qi2G#LIQ)TF|iHl~WKK6JmK zbv7rPE6VkYr_%_BT}CK8h=?%pk@3cz(UrZ{@h40%XgThP*-Oeo`T0eq9 zA8BnWZKzCy5e&&_GEsU4*;_k}(8l_&al5K-V*BFM=O~;MgRkYsOs%9eOY6s6AtE*<7GQAR2ulC3RAJrG_P1iQK5Z~&B z&f8X<>yJV6)oDGIlS$Y*D^Rj(cszTy5c81a5IwBr`BtnC6_e`ArI8CaTX_%rx7;cn zR-0?J_LFg*?(#n~G8cXut(1nVF0Oka$A$1FGcERU<^ggx;p@CZc?3UB41RY+wLS`LWFNSs~YP zuw1@DNN3lTd|jDL7gjBsd9}wIw}4xT2+8dBQzI00m<@?c2L%>}QLfK5%r!a-iII`p zX@`VEUH)uj^$;7jVUYdADQ2k*!1O3WdfgF?OMtUXNpQ1}QINamBTKDuv19^{$`8A1 zeq%q*O0mi@(%sZU>Xdb0Ru96CFqk9-L3pzLVsMQ`Xpa~N6CR{9Rm2)A|CI21L(%GW zh&)Y$BNHa=FD+=mBw3{qTgw)j0b!Eahs!rZnpu)z!!E$*eXE~##yaXz`KE5(nQM`s zD!$vW9XH)iMxu9R>r$VlLk9oIR%HxpUiW=BK@4U)|1WNQ=mz9a z^!KkO=>GaJ!GBXm{KJj^;kh-MkUlEQ%lza`-G&}C5y1>La1sR6hT=d*NeCnuK%_LV zOXt$}iP6(YJKc9j-Fxq~*ItVUqljQ8?oaysB-EYtFQp9oxZ|5m0^Hq(qV!S+hq#g( z?|i*H2MIr^Kxgz+3vIljQ*Feejy6S4v~jKEPTF~Qhq!(ms5>NGtRgO5vfPPc4Z^AM zTj!`5xEreIN)vaNxa|q6qWdg>+T`Ol0Uz)ckXBXEGvPNEL3R8hB3=C5`@=SYgAju1 z!)UBr{2~=~xa{b8>x2@C7weRAEuatC)3pkRhT#pMPTpSbA|tan%U7NGMvzmF?c!V8 z=pEWxbdXbTAGtWTyI?Fml%lEr-^AE}w#l(<7OIw;ctw}imYax&vR4UYNJZK6P7ZOd zP87XfhnUHxCUHhM@b*NbTi#(-8|wcv%3BGNs#zRCVV(W?1Qj6^PPQa<{yaBwZ`+<`w|;rqUY_C z&AeyKwwf*q#OW-F()lir=T^<^wjK65Lif$puuU5+tk$;e_EJ;Lu+pH>=-8=PDhkBg z8cWt%@$Sc#C6F$Vd+0507;{OOyT7Hs%nKS88q-W!$f~9*WGBpHGgNp}=C*7!RiZ5s zn1L_DbKF@B8kwhDiLKRB@lsXVVLK|ph=w%_`#owlf@s@V(pa`GY$8h%;-#h@TsO|Y8V=n@*!Rog7<7Cid%apR|x zOjhHCyfbIt%+*PCveTEcuiDi%Wx;O;+K=W?OFUV%)%~6;gl?<0%)?snDDqIvkHF{ zyI02)+lI9ov42^hL>ZRrh*HhjF9B$A@=H94iaBESBF=eC_KT$8A@uB^6$~o?3Wm5t1OIaqF^~><2?4e3c&)@wKn9bD? zoeCs;H>b8DL^F&>Xw-xjZEUFFTv>JD^O#1E#)CMBaG4DX9bD(Wtc8Rzq}9soQ8`jf zeSnHOL}<+WVSKp4kkq&?SbETjq6yr@4%SAqOG=9E(3YeLG9dtV+8vmzq+6PFPk{L; z(&d++iu=^F%b+ea$i2UeTC{R*0Isk;vFK!no<;L+(`y`3&H-~VTdKROkdyowo1iqR zbVW(3`+(PQ2>TKY>N!jGmGo7oeoB8O|P_!Ic@ zZ^;3dnuXo;WJ?S+)%P>{Hcg!Jz#2SI(s&dY4QAy_vRlmOh)QHvs_7c&zkJCmJGVvV zX;Mtb>QE+xp`KyciG$Cn*0?AK%-a|=o!+7x&&yzHQOS>8=B*R=niSnta^Pxp1`=md z#;$pS$4WCT?mbiCYU?FcHGZ#)kHVJTTBt^%XE(Q};aaO=Zik0UgLcc0I(tUpt(>|& zcxB_|fxCF7>&~5eJ=Dpn&5Aj{A^cV^^}(7w#p;HG&Q)EaN~~EqrE1qKrMAc&WXIE;>@<&)5;gD2?={Xf@Mvn@OJKw=8Mgn z!JUFMwD+s==JpjhroT&d{$kQAy%+d`a*XxDEVxy3`NHzmITrE`o!;5ClXNPb4t*8P zzAivdr{j_v!=9!^?T3y?gzmqDWX6mkzhIzJ-3S{T5bcCFMr&RPDryMcdwbBuZbsgN zGrp@^i?rcfN7v0NKGzDPGE#4yszxu=I_`MI%Z|10nFjU-UjQXXA?k8Pk|OE<(?ae) zE%vG#eZAlj*E7_3dx#Zz4kMLj>H^;}33UAankJiDy5ZvEhrjr`!9eMD8COp}U*hP+ zF}KIYx@pkccIgyxFm#LNw~G&`;o&5)2`5aogs`1~7cMZQ7zj!%L4E`2yzlQN6REX20&O<9 zKV6fyr)TScJPPzNTC2gL+0x#=u>(({{D7j)c-%tvqls3#Y?Z1m zV5WUE)zdJ{$p>yX;^P!UcXP?UD~YM;IRa#Rs5~l+*$&nO(;Ers`G=0D!twR(0GF@c zHl9E5DQI}Oz74n zfKP>&$q0($T4y$6w(p=ERAFh+>n%iaeRA%!T%<^+pg?M)@ucY<&59$x9M#n+V&>}=nO9wCV{O~lg&v#+jcUj(tQ z`0u1YH)-`U$15a{pBkGyPL0THv1P|4e@pf@3IBZS4dVJPo#H>pWq%Lr0YS-SeWash z8R7=jb28KPMI|_lo#GEO|5B?N_e``H*23{~a!AmUJ+fb4HX-%QI@lSEUxKlGV7z7Q zSKw@-TR>@1RL%w{x}dW#k1NgW+q4yt2Xf1J62Bx*O^WG8OJ|FqI4&@d3_o8Id@*)4 zYrk=>@!wv~mh7YWv*bZhxqSmFh2Xq)o=m;%n$I?GSz49l1$xRpPu_^N(vZ>*>Z<04 z2+rP70oM=NDysd!@fQdM2OcyT?3T^Eb@lIC-UG=Bw{BjQ&P`KCv$AcJ;?`vdZ4){d z&gkoUK{$!$$K`3*O-jyM1~p-7T*qb)Ys>Myt^;#1&a%O@x8A+E>! zY8=eD`ZG)LVagDLBeHg>=atOG?Kr%h4B%E6m@J^C+U|y)XX@f z8oyJDW|9g=<#f<{JRr{y#~euMnv)`7j=%cHWLc}ngjq~7k**6%4u>Px&W%4D94(r* z+akunK}O0DC2A%Xo9jyF;DobX?!1I(7%}@7F>i%&nk*LMO)bMGg2N+1iqtg+r(70q zF5{Msgsm5GS7DT`kBsjMvOrkx&|EU!{{~gL4d2MWrAT=KBQ-^zQCUq{5PD1orxlIL zq;CvlWx#f1NWvh`hg011I%?T_s!e38l*lWVt|~z-PO4~~1g)SrJ|>*tXh=QfXT)%( z+ex+inPvD&O4Ur;JGz>$sUOnWdpSLcm1X%aQDw4{dB!cnj`^muI$CJ2%p&-kULVCE z>$eMR36kN$wCPR+OFDM3-U(VOrp9k3)lI&YVFqd;Kpz~K)@Fa&FRw}L(SoD z9B4a+hQzZT-BnVltst&=kq6Y(f^S4hIGNKYBgMxGJ^;2yrO}P3;r)(-I-CZ)26Y6? z&rzHI_1GCvGkgy-t1E;r^3Le30|%$ebDRu2+gdLG)r=A~Qz`}~&L@aGJ{}vVs_GE* zVUjFnzHiXfKQbpv&bR&}l2bzIjAooB)=-XNcYmrGmBh(&iu@o!^hn0^#}m2yZZUK8 zufVm7Gq0y`Mj;9b>`c?&PZkU0j4>IL=UL&-Lp3j&47B5pAW4JceG{!XCA)kT<%2nqCxj<)uy6XR_uws~>_MEKPOpAQ!H zkn>FKh)<9DwwS*|Y(q?$^N!6(51O0 z^JM~Ax{AI1Oj$fs-S5d4T7Z_i1?{%0SsIuQ&r8#(JA=2iLcTN+?>wOL532%&dMYkT z*T5xepC+V6zxhS@vNbMoi|i)=rpli@R9~P!39tWbSSb904ekv7D#quKbgFEMTb48P zuq(VJ+&L8aWU(_FCD$3^uD!YM%O^K(dvy~Wm2hUuh6bD|#(I39Xt>N1Y{ZqXL`Fg6 zKQ?T2htHN!(Bx;tV2bfTtIj7e)liN-29s1kew>v(D^@)#v;}C4-G=7x#;-dM4yRWm zyY`cS21ulzMK{PoaQ6xChEZ}o_#}X-o}<&0)$1#3we?+QeLt;aVCjeA)hn!}UaKt< zat1fHEx13y-rXNMvpUUmCVzocPmN~-Y4(YJvQ#db)4|%B!rBsgAe+*yor~}FrNH08 z3V!97S}D7d$zbSD{$z;@IYMxM6aHdypIuS*pr_U6;#Y!_?0i|&yU*@16l z*dcMqDQgfNBf}?quiu4e>H)yTVfsp#f+Du0@=Kc41QockXkCkvu>FBd6Q+@FL!(Yx z2`YuX#eMEiLEDhp+9uFqME_E^faV&~9qjBHJkIp~%$x^bN=N)K@kvSVEMdDuzA0sn z88CBG?`RX1@#hQNd`o^V{37)!w|nA)QfiYBE^m=yQKv-fQF+UCMcuEe1d4BH7$?>b zJl-r9@0^Ie=)guO1vOd=i$_4sz>y3x^R7n4ED!5oXL3@5**h(xr%Hv)_gILarO46q+MaDOF%ChaymKoI6JU5Pg;7#2n9-18|S1;AK+ zgsn6;k6-%!QD>D?cFy}8F;r@z8H9xN1jsOBw2vQONVqBVEbkiNUqgw~*!^##ht>w0 zUOykwH=$LwX2j&nLy=@{hr)2O&-wm-NyjW7n~Zs9UlH;P7iP3 zI}S(r0YFVYacnKH(+{*)Tbw)@;6>%=&Th=+Z6NHo_tR|JCI8TJiXv2N7ei7M^Q+RM z?9o`meH$5Yi;@9XaNR#jIK^&{N|DYNNbtdb)XW1Lv2k{E>;?F`#Pq|&_;gm~&~Zc9 zf+6ZE%{x4|{YdtE?a^gKyzr}dA>OxQv+pq|@IXL%WS0CiX!V zm$fCePA%lU{%pTKD7|5NJHeXg=I0jL@$tOF@K*MI$)f?om)D63K*M|r`gb9edD1~Y zc|w7N)Y%do7=0{RC|AziW7#am$)9jciRJ?IWl9PE{G3U+$%FcyKs_0Cgq`=K3@ttV z9g;M!3z~f_?P%y3-ph%vBMeS@p7P&Ea8M@97+%XEj*(1E6vHj==d zjsoviB>j^$_^OI_DEPvFkVo(BGRo%cJeD){6Uckei=~1}>sp299|IRjhXe)%?uP0I zF5+>?0#Ye}T^Y$u_rc4=lPcq4K^D(TZG-w30-YiEM=dcK+4#o*>lJ8&JLi+3UcpZk z!^?95S^C0ja^jwP`|{<+3cBVog$(mRdQmadS+Vh~z zS@|P}=|z3P6uS+&@QsMp0no9Od&27O&14zHXGAOEy zh~OKpymK5C%;LLb467@KgIiVwYbYd6wFxI{0-~MOGfTq$nBTB!{SrWmL9Hs}C&l&l#m?s*{tA?BHS4mVKHAVMqm63H<|c5n0~k)-kbg zXidai&9ZUy0~WFYYKT;oe~rytRk?)r8bptITsWj(@HLI;@=v5|XUnSls7$uaxFRL+ zRVMGuL3w}NbV1`^=Pw*0?>bm8+xfeY(1PikW*PB>>Tq(FR`91N0c2&>lL2sZo5=VD zQY{>7dh_TX98L2)n{2OV=T10~*YzX27i2Q7W86M4$?gZIXZaBq#sA*{PH8){|GUi;oM>e?ua7eF4WFuFYZSG| zze?srg|5Ti8Og{O zeFxuw9!U+zhyk?@w zjsA6(oKD=Ka;A>Ca)oPORxK+kxH#O@zhC!!XS4@=swnuMk>t+JmLmFiE^1aX3f<)D@`%K0FGK^gg1a1j>zi z2KhV>sjU7AX3F$SEqrXSC}fRx64GDoc%!u2Yag68Lw@w9v;xOONf@o)Lc|Uh3<21ctTYu-mFZuHk*+R{GjXHIGq3p)tFtQp%TYqD=j1&y)>@zxoxUJ!G@ zgI0XKmP6MNzw>nRxK$-Gbzs}dyfFzt>#5;f6oR27ql!%+{tr+(`(>%51|k`ML} zY4eE)Lxq|JMas(;JibNQds1bUB&r}ydMQXBY4x(^&fY_&LlQC)3hylc$~8&~|06-D z#T+%66rYbHX%^KuqJED_wuGB+=h`nWA!>1n0)3wZrBG3%`b^Ozv6__dNa@%V14|!D zQ?o$z5u0^8`giv%qE!BzZ!3j;BlDlJDk)h@9{nSQeEk!z9RGW) z${RSF3phEM*ce*>Xdp}585vj$|40=&S{S-GTiE?Op*vY&Lvr9}BO$XWy80IF+6@%n z5*2ueT_g@ofP#u5pxb7n*fv^Xtt7&?SRc{*2Ka-*!BuOpf}neHGCiHy$@Ka1^Dint z;DkmIL$-e)rj4o2WQV%Gy;Xg(_Bh#qeOsTM2f@KEe~4kJ8kNLQ+;(!j^bgJMcNhvklP5Z6I+9Fq@c&D~8Fb-4rmDT!MB5QC{Dsb;BharP*O;SF4& zc$wj-7Oep7#$WZN!1nznc@Vb<_Dn%ga-O#J(l=OGB`dy=Sy&$(5-n3zzu%d7E#^8`T@}V+5B;PP8J14#4cCPw-SQTdGa2gWL0*zKM z#DfSXs_iWOMt)0*+Y>Lkd=LlyoHjublNLefhKBv@JoC>P7N1_#> zv=mLWe96%EY;!ZGSQDbZWb#;tzqAGgx~uk+-$+2_8U`!ypbwXl z^2E-FkM1?lY@yt8=J3%QK+xaZ6ok=-y%=KXCD^0r!5vUneW>95PzCkOPO*t}p$;-> ze5j-BLT_;)cZQzR2CEsm@rU7GZfFtdp*a|g4wDr%8?2QkIGasRfDWT-Dvy*U{?IHT z*}wGnzdlSptl#ZF^sf)KT|BJs&kLG91^A6ls{CzFprZ6-Y!V0Xysh%9p%iMd7HLsS zN+^Un$tDV)T@i!v?3o0Fsx2qI(AX_$dDkBzQ@fRM%n zRXk6hb9Py#JXUs+7)w@eo;g%QQ95Yq!K_d=z{0dGS+pToEI6=Bo8+{k$7&Z zo4>PH(`ce8E-Ps&uv`NQ;U$%t;w~|@E3WVOCi~R4oj5wP?%<*1C%}Jq%a^q~T7u>K zML5AKfQDv6>PuT`{SrKHRAF+^&edg6+5R_#H?Lz3iGoWo#PCEd0DS;)2U({{X#zU^ zw_xv{4x7|t!S)>44J;KfA|DC?;uQ($l+5Vp7oeqf7{GBF9356nx|&B~gs+@N^gSdd zvb*>&W)|u#F{Z_b`f#GVtQ`pYv3#||N{xj1NgB<#=Odt6{eB%#9RLt5v zIi|0u70`#ai}9fJjKv7dE!9ZrOIX!3{$z_K5FBd-Kp-&e4(J$LD-)NMTp^_pB`RT; zftVVlK2g@+1Ahv2$D){@Y#cL#dUj9*&%#6 zd2m9{1NYp>)6=oAvqdCn5#cx{AJ%S8skUgMglu2*IAtd+z1>B&`MuEAS(D(<6X#Lj z?f4CFx$)M&$=7*>9v1ER4b6!SIz-m0e{o0BfkySREchp?WdVPpQCh!q$t>?rL!&Jg zd#heM;&~A}VEm8Dvy&P|J*eAV&w!&Nx6HFV&B8jJFVTmgLaswn!cx$&%JbTsloz!3 zMEz1d`k==`Ueub_JAy_&`!ogbwx27^ZXgFNAbx=g_I~5nO^r)}&myw~+yY*cJl4$I znNJ32M&K=0(2Dj_>@39`3=FX!v3nZHno_@q^!y}%(yw0PqOo=);6Y@&ylVe>nMOZ~ zd>j#QQSBn3oaWd;qy$&5(5H$Ayi)0haAYO6TH>FR?rhqHmNOO+(})NB zLI@B@v0)eq!ug`>G<@htRlp3n!EpU|n+G+AvXFrWSUsLMBfL*ZB`CRsIVHNTR&b?K zxBgsN0BjfB>UVcJ|x%=-zb%OV7lmZc& zxiupadZVF7)6QuhoY;;FK2b*qL0J-Rn-8!X4ZY$-ZSUXV5DFd7`T41c(#lAeLMoeT z4%g655v@7AqT!i@)Edt5JMbN(=Q-6{=L4iG8RA%}w;&pKmtWvI4?G9pVRp|RTw`g0 zD5c12B&A2&P6Ng~8WM2eIW=wxd?r7A*N+&!Be7PX3s|7~z=APxm=A?5 zt>xB4WG|*Td@VX{Rs)PV0|yK`oI3^xn(4c_j&vgxk_Y3o(-`_5o`V zRTghg6%l@(qodXN;dB#+OKJEEvhfcnc#BeO2|E(5df-!fKDZ!%9!^BJ_4)9P+9Dq5 zK1=(v?KmIp34r?z{NEWnLB3Px{XYwy-akun4F7xTRr2^zeYW{gcK9)>aJDdU5;w5@ zak=<+-PLH-|04pelTb%ULpuuuJC7DgyT@D|p{!V!0v3KpDnRjANN12q6SUR3mb9<- z>2r~IApQGhstZ!3*?5V z8#)hJ0TdZg0M-BK#nGFP>$i=qk82DO z7h;Ft!D5E15OgW)&%lej*?^1~2=*Z5$2VX>V{x8SC+{i10BbtUk9@I#Vi&hX)q
Q!LwySI{Bnv%Sm)yh{^sSVJ8&h_D-BJ_YZe5eCaAWU9b$O2c z$T|{vWVRtOL!xC0DTc(Qbe`ItNtt5hr<)VijD0{U;T#bUEp381_y`%ZIav?kuYG{iyYdEBPW=*xNSc;Rlt6~F4M`5G+VtOjc z*0qGzCb@gME5udTjJA-9O<&TWd~}ysBd(eVT1-H82-doyH9RST)|+Pb{o*;$j9Tjs zhU!IlsPsj8=(x3bAKJTopW3^6AKROHR^7wZ185wJGVhA~hEc|LP;k7NEz-@4p5o}F z`AD6naG3(n=NF9HTH81=F+Q|JOz$7wm9I<+#BSmB@o_cLt2GkW9|?7mM;r!JZp89l zbo!Hp8=n!XH1{GwaDU+k)pGp`C|cXkCU5%vcH)+v@0eK>%7gWxmuMu9YLlChA|_D@ zi#5zovN_!a-0?~pUV-Rj*1P)KwdU-LguR>YM&*Nen+ln8Q$?WFCJg%DY%K}2!!1FE zDv-A%Cbwo^p(lzac&_TZ-l#9kq`mhLcY3h9ZTUVCM(Ad&=EriQY5{jJv<5K&g|*Lk zgV%ILnf1%8V2B0E&;Sp4sYbYOvvMebLwYwzkRQ#F8GpTQq#uv=J`uaSJ34OWITeSGo6+-8Xw znCk*n{kdDEi)Hi&u^)~cs@iyCkFWB2SWZU|Uc%^43ZIZQ-vWNExCCtDWjqHs;;tWf$v{}0{p0Rvxkq``)*>+Akq%|Na zA`@~-Vfe|+(AIlqru+7Ceh4nsVmO9p9jc8}HX^W&ViBDXT+uXbT#R#idPn&L>+#b6 zflC-4C5-X;kUnR~L>PSLh*gvL68}RBsu#2l`s_9KjUWRhiqF`j)`y`2`YU(>3bdBj z?>iyjEhe-~$^I5!nn%B6Wh+I`FvLNvauve~eX<+Ipl&04 zT}};W&1a3%W?dJ2=N#0t?e+aK+%t}5q%jSLvp3jZ%?&F}nOOWr>+{GFIa%wO_2`et z=JzoRR~}iKuuR+azPI8;Gf9)z3kyA4EIOSl!sRR$DlW}0>&?GbgPojmjmnln;cTqCt=ADbE zZ8GAnoM+S1(5$i8^O4t`ue;vO4i}z0wz-QEIVe5_u03;}-!G1NyY8;h^}y;tzY}i5 zqQr#Ur3Fy8sSa$Q0ys+f`!`+>9WbvU_I`Sj;$4{S>O3?#inLHCrtLy~!s#WXV=oVP zeE93*Nc`PBi4q@%Ao$x4lw9vLHM!6mn3-b_cebF|n-2vt-zYVF_&sDE--J-P;2WHo z+@n2areE0o$LjvjlV2X7ZU@j+`{*8zq`JR3gKF#EW|#+{nMyo-a>nFFTg&vhyT=b} zDa8+v0(Dgx0yRL@ZXOYIlVSZ0|MFizy0VPW8;AfA5|pe!#j zX}Py^8fl5SyS4g1WSKKtnyP+_PoOwMMwu`(i@Z)diJp~U54*-miOchy7Z35eL>^M z4p<-aIxH4VUZgS783@H%M7P9hX>t{|RU7$n4T(brCG#h9e9p! z+o`i;EGGq3&pF;~5V~eBD}lC)>if$w%Vf}AFxGqO88|ApfHf&Bvu+xdG)@vuF}Yvk z)o;~k-%+0K0g+L`Wala!$=ZV|z$e%>f0%XoLib%)!R^RoS+{!#X?h-6uu zF&&KxORdZU&EwQFITIRLo(7TA3W}y6X{?Y%y2j0It!ekU#<)$qghZtpcS>L3uh`Uj z7GY;6f$9qKynP#oS3$$a{p^{D+0oJQ71`1?OAn_m8)UGZmj3l*ZI)`V-a>MKGGFG< z&^jg#Ok%(hhm>hSrZ5;Qga4u(?^i>GiW_j9%_7M>j(^|Om$#{k+^*ULnEgzW_1gCICtAD^WpC`A z{9&DXkG#01Xo)U$OC(L5Y$DQ|Q4C6CjUKk1UkPj$nXH##J{c8e#K|&{mA*;b$r0E4 zUNo0jthwA(c&N1l=PEe8Rw_8cEl|-eya9z&H3#n`B$t#+aJ03RFMzrV@gowbe8v(c zIFM60^0&lCFO10NU4w@|61xiZ4CVXeaKjd;d?sv52XM*lS8XiVjgWpRB;&U_C0g+`6B5V&w|O6B*_q zsATxL!M}+$He)1eOWECce#eS@2n^xhlB4<_Nn?yCVEQWDs(r`|@2GqLe<#(|&P0U? z$7V5IgpWf09uIf_RazRwC?qEqRaHyL?iiS05UiGesJy%^>-C{{ypTBI&B0-iUYhk> zIk<5xpsuV@g|z(AZD+C-;A!fTG=df1=<%nxy(a(IS+U{ME4ZbDEBtcD_3V=icT6*_ z)>|J?>&6%nvHhZERBtjK+s4xnut*@>GAmA5m*OTp$!^CHTr}vM4n(X1Q*;{e-Rd2BCF-u@1ZGm z!S8hJ6L=Gl4T_SDa7Xx|-{4mxveJg=ctf`BJ*fy!yF6Dz&?w(Q_6B}WQVtNI!BVBC zKfX<>7vd6C96}XAQmF-Jd?1Q4eTfRB3q7hCh0f!(JkdWT5<{iAE#dKy*Jxq&3a1@~ z8C||Dn2mFNyrUV|<-)C^_y7@8c2Fz+2jrae9deBDu;U}tJ{^xAdxCD248(k;dCJ%o z`y3sADe>U%suxwwv~8A1+R$VB=Q?%U?4joI$um;aH+eCrBqpn- z%79D_7rb;R-;-9RTrwi9dPlg8&@tfWhhZ(Vx&1PQ+6(huX`;M9x~LrW~~#3{j0Bh2kDU$}@!fFQej4VGkJv?M4rU^x!RU zEwhu$!CA_iDjFjrJa`aocySDX16?~;+wgav;}Zut6Mg%C4>}8FL?8)Kgwc(Qlj{@#2Pt0?G`$h7P#M+qoXtlV@d}%c&OzO+QYKK`kyXaK{U(O^2DyIXCZlNQjt0^8~8JzNGrIxhj}}M z&~QZlbx%t;MJ(Vux;2tgNKGlAqphLq%pd}JG9uoVHUo?|hN{pLQ6Em%r*+7t^<);X zm~6=qChlNAVXNN*Sow->*4;}T;l;D1I-5T{Bif@4_}=>l`tK;qqDdt5zvisCKhMAH z#r}`)7VW?LZqfdmXQ%zo5bJ00{Xb9^YKrk0Nf|oIW*K@(=`o2Vndz}ZDyk{!u}PVx zzd--+_WC*U{~DH3{?GI64IB+@On&@9X>EUAo&L+G{L^dozaI4C3G#2wr~hseW@K&g zKWs{uHu-9Je!3;4pE>eBltKUXb^*hG8I&413)$J&{D4N%7PcloU6bn%jPxJyQL?g* z9g+YFFEDiE`8rW^laCNzQmi7CTnPfwyg3VDHRAl>h=In6jeaVOP@!-CP60j3+#vpL zEYmh_oP0{-gTe7Or`L6x)6w?77QVi~jD8lWN@3RHcm80iV%M1A!+Y6iHM)05iC64tb$X2lV_%Txk@0l^hZqi^%Z?#- zE;LE0uFx)R08_S-#(wC=dS&}vj6P4>5ZWjhthP=*Hht&TdLtKDR;rXEX4*z0h74FA zMCINqrh3Vq;s%3MC1YL`{WjIAPkVL#3rj^9Pj9Ss7>7duy!9H0vYF%>1jh)EPqvlr6h%R%CxDsk| z!BACz7E%j?bm=pH6Eaw{+suniuY7C9Ut~1cWfOX9KW9=H><&kQlinPV3h9R>3nJvK z4L9(DRM=x;R&d#a@oFY7mB|m8h4692U5eYfcw|QKwqRsshN(q^v$4$)HgPpAJDJ`I zkqjq(8Cd!K!+wCd=d@w%~e$=gdUgD&wj$LQ1r>-E=O@c ze+Z$x{>6(JA-fNVr)X;*)40Eym1TtUZI1Pwwx1hUi+G1Jlk~vCYeXMNYtr)1?qwyg zsX_e*$h?380O00ou?0R@7-Fc59o$UvyVs4cUbujHUA>sH!}L54>`e` zHUx#Q+Hn&Og#YVOuo*niy*GU3rH;%f``nk#NN5-xrZ34NeH$l`4@t);4(+0|Z#I>Y z)~Kzs#exIAaf--65L0UHT_SvV8O2WYeD>Mq^Y6L!Xu8%vnpofG@w!}R7M28?i1*T&zp3X4^OMCY6(Dg<-! zXmcGQrRgHXGYre7GfTJ)rhl|rs%abKT_Nt24_Q``XH{88NVPW+`x4ZdrMuO0iZ0g` z%p}y};~T5gbb9SeL8BSc`SO#ixC$@QhXxZ=B}L`tP}&k?1oSPS=4%{UOHe0<_XWln zwbl5cn(j-qK`)vGHY5B5C|QZd5)W7c@{bNVXqJ!!n$^ufc?N9C-BF2QK1(kv++h!>$QbAjq)_b$$PcJdV+F7hz0Hu@ zqj+}m0qn{t^tD3DfBb~0B36|Q`bs*xs|$i^G4uNUEBl4g;op-;Wl~iThgga?+dL7s zUP(8lMO?g{GcYpDS{NM!UA8Hco?#}eNEioRBHy4`mq!Pd-9@-97|k$hpEX>xoX+dY zDr$wfm^P&}Wu{!%?)U_(%Mn79$(ywvu*kJ9r4u|MyYLI_67U7%6Gd_vb##Nerf@>& z8W11z$$~xEZt$dPG}+*IZky+os5Ju2eRi;1=rUEeIn>t-AzC_IGM-IXWK3^6QNU+2pe=MBn4I*R@A%-iLDCOHTE-O^wo$sL_h{dcPl=^muAQb`_BRm};=cy{qSkui;`WSsj9%c^+bIDQ z0`_?KX0<-=o!t{u(Ln)v>%VGL z0pC=GB7*AQ?N7N{ut*a%MH-tdtNmNC+Yf$|KS)BW(gQJ*z$d{+{j?(e&hgTy^2|AR9vx1Xre2fagGv0YXWqtNkg*v%40v?BJBt|f9wX5 z{QTlCM}b-0{mV?IG>TW_BdviUKhtosrBqdfq&Frdz>cF~yK{P@(w{Vr7z2qKFwLhc zQuogKO@~YwyS9%+d-zD7mJG~@?EFJLSn!a&mhE5$_4xBl&6QHMzL?CdzEnC~C3$X@ zvY!{_GR06ep5;<#cKCSJ%srxX=+pn?ywDwtJ2{TV;0DKBO2t++B(tIO4)Wh`rD13P z4fE$#%zkd=UzOB74gi=-*CuID&Z3zI^-`4U^S?dHxK8fP*;fE|a(KYMgMUo`THIS1f!*6dOI2 zFjC3O=-AL`6=9pp;`CYPTdVX z8(*?V&%QoipuH0>WKlL8A*zTKckD!paN@~hh zmXzm~qZhMGVdQGd=AG8&20HW0RGV8X{$9LldFZYm zE?}`Q3i?xJRz43S?VFMmqRyvWaS#(~Lempg9nTM$EFDP(Gzx#$r)W&lpFKqcAoJh-AxEw$-bjW>`_+gEi z2w`99#UbFZGiQjS8kj~@PGqpsPX`T{YOj`CaEqTFag;$jY z8_{Wzz>HXx&G*Dx<5skhpETxIdhKH?DtY@b9l8$l?UkM#J-Snmts7bd7xayKTFJ(u zyAT&@6cAYcs{PBfpqZa%sxhJ5nSZBPji?Zlf&}#L?t)vC4X5VLp%~fz2Sx<*oN<7` z?ge=k<=X7r<~F7Tvp9#HB{!mA!QWBOf%EiSJ6KIF8QZNjg&x~-%e*tflL(ji_S^sO ztmib1rp09uon}RcsFi#k)oLs@$?vs(i>5k3YN%$T(5Or(TZ5JW9mA6mIMD08=749$ z!d+l*iu{Il7^Yu}H;lgw=En1sJpCKPSqTCHy4(f&NPelr31^*l%KHq^QE>z>Ks_bH zjbD?({~8Din7IvZeJ>8Ey=e;I?thpzD=zE5UHeO|neioJwG;IyLk?xOz(yO&0DTU~ z^#)xcs|s>Flgmp;SmYJ4g(|HMu3v7#;c*Aa8iF#UZo7CvDq4>8#qLJ|YdZ!AsH%^_7N1IQjCro

K7UpUK$>l@ zw`1S}(D?mUXu_C{wupRS-jiX~w=Uqqhf|Vb3Cm9L=T+w91Cu^ z*&Ty%sN?x*h~mJc4g~k{xD4ZmF%FXZNC;oVDwLZ_WvrnzY|{v8hc1nmx4^}Z;yriXsAf+Lp+OFLbR!&Ox?xABwl zu8w&|5pCxmu#$?Cv2_-Vghl2LZ6m7}VLEfR5o2Ou$x02uA-%QB2$c(c1rH3R9hesc zfpn#oqpbKuVsdfV#cv@5pV4^f_!WS+F>SV6N0JQ9E!T90EX((_{bSSFv9ld%I0&}9 zH&Jd4MEX1e0iqDtq~h?DBrxQX1iI0lIs<|kB$Yrh&cpeK0-^K%=FBsCBT46@h#yi!AyDq1V(#V}^;{{V*@T4WJ&U-NTq43w=|K>z8%pr_nC>%C(Wa_l78Ufib$r8Od)IIN=u>417 z`Hl{9A$mI5A(;+-Q&$F&h-@;NR>Z<2U;Y21>>Z;s@0V@SbkMQQj%_;~+qTuQ?c|AV zcWm3XZQHhP&R%QWarS%mJ!9R^&!_)*s(v+VR@I#QrAT}`17Y+l<`b-nvmDNW`De%y zrwTZ9EJrj1AFA>B`1jYDow}~*dfPs}IZMO3=a{Fy#IOILc8F0;JS4x(k-NSpbN@qM z`@aE_e}5{!$v3+qVs7u?sOV(y@1Os*Fgu`fCW9=G@F_#VQ%xf$hj0~wnnP0$hFI+@ zkQj~v#V>xn)u??YutKsX>pxKCl^p!C-o?+9;!Nug^ z{rP!|+KsP5%uF;ZCa5F;O^9TGac=M|=V z_H(PfkV1rz4jl?gJ(ArXMyWT4y(86d3`$iI4^l9`vLdZkzpznSd5Ikfrs8qcSy&>z zTIZgWZGXw0n9ibQxYWE@gI0(3#KA-dAdPcsL_|hg2@~C!VZDM}5;v_Nykfq!*@*Zf zE_wVgx82GMDryKO{U{D>vSzSc%B~|cjDQrt5BN=Ugpsf8H8f1lR4SGo#hCuXPL;QQ z#~b?C4MoepT3X`qdW2dNn& zo8)K}%Lpu>0tQei+{>*VGErz|qjbK#9 zvtd8rcHplw%YyQCKR{kyo6fgg!)6tHUYT(L>B7er5)41iG`j$qe*kSh$fY!PehLcD zWeKZHn<492B34*JUQh=CY1R~jT9Jt=k=jCU2=SL&&y5QI2uAG2?L8qd2U(^AW#{(x zThSy=C#>k+QMo^7caQcpU?Qn}j-`s?1vXuzG#j8(A+RUAY})F@=r&F(8nI&HspAy4 z4>(M>hI9c7?DCW8rw6|23?qQMSq?*Vx?v30U%luBo)B-k2mkL)Ljk5xUha3pK>EEj z@(;tH|M@xkuN?gsz;*bygizwYR!6=(Xgcg^>WlGtRYCozY<rFX2E>kaZo)O<^J7a`MX8Pf`gBd4vrtD|qKn&B)C&wp0O-x*@-|m*0egT=-t@%dD zgP2D+#WPptnc;_ugD6%zN}Z+X4=c61XNLb7L1gWd8;NHrBXwJ7s0ce#lWnnFUMTR& z1_R9Fin4!d17d4jpKcfh?MKRxxQk$@)*hradH2$3)nyXep5Z;B z?yX+-Bd=TqO2!11?MDtG0n(*T^!CIiF@ZQymqq1wPM_X$Iu9-P=^}v7npvvPBu!d$ z7K?@CsA8H38+zjA@{;{kG)#AHME>Ix<711_iQ@WWMObXyVO)a&^qE1GqpP47Q|_AG zP`(AD&r!V^MXQ^e+*n5~Lp9!B+#y3#f8J^5!iC@3Y@P`;FoUH{G*pj*q7MVV)29+j z>BC`a|1@U_v%%o9VH_HsSnM`jZ-&CDvbiqDg)tQEnV>b%Ptm)T|1?TrpIl)Y$LnG_ zzKi5j2Fx^K^PG1=*?GhK;$(UCF-tM~^=Z*+Wp{FSuy7iHt9#4n(sUuHK??@v+6*|10Csdnyg9hAsC5_OrSL;jVkLlf zHXIPukLqbhs~-*oa^gqgvtpgTk_7GypwH><53riYYL*M=Q@F-yEPLqQ&1Sc zZB%w}T~RO|#jFjMWcKMZccxm-SL)s_ig?OC?y_~gLFj{n8D$J_Kw%{r0oB8?@dWzn zB528d-wUBQzrrSSLq?fR!K%59Zv9J4yCQhhDGwhptpA5O5U?Hjqt>8nOD zi{)0CI|&Gu%zunGI*XFZh(ix)q${jT8wnnzbBMPYVJc4HX*9d^mz|21$=R$J$(y7V zo0dxdbX3N#=F$zjstTf*t8vL)2*{XH!+<2IJ1VVFa67|{?LP&P41h$2i2;?N~RA30LV`BsUcj zfO9#Pg1$t}7zpv#&)8`mis3~o+P(DxOMgz-V*(?wWaxi?R=NhtW}<#^Z?(BhSwyar zG|A#Q7wh4OfK<|DAcl9THc-W4*>J4nTevsD%dkj`U~wSUCh15?_N@uMdF^Kw+{agk zJ`im^wDqj`Ev)W3k3stasP`88-M0ZBs7;B6{-tSm3>I@_e-QfT?7|n0D~0RRqDb^G zyHb=is;IwuQ&ITzL4KsP@Z`b$d%B0Wuhioo1CWttW8yhsER1ZUZzA{F*K=wmi-sb#Ju+j z-l@In^IKnb{bQG}Ps>+Vu_W#grNKNGto+yjA)?>0?~X`4I3T@5G1)RqGUZuP^NJCq&^HykuYtMDD8qq+l8RcZNJsvN(10{ zQ1$XcGt}QH-U^WU!-wRR1d--{B$%vY{JLWIV%P4-KQuxxDeJaF#{eu&&r!3Qu{w}0f--8^H|KwE>)ORrcR+2Qf zb})DRcH>k0zWK8@{RX}NYvTF;E~phK{+F;MkIP$)T$93Ba2R2TvKc>`D??#mv9wg$ zd~|-`Qx5LwwsZ2hb*Rt4S9dsF%Cny5<1fscy~)d;0m2r$f=83<->c~!GNyb!U)PA; zq^!`@@)UaG)Ew(9V?5ZBq#c%dCWZrplmuM`o~TyHjAIMh0*#1{B>K4po-dx$Tk-Cq z=WZDkP5x2W&Os`N8KiYHRH#UY*n|nvd(U>yO=MFI-2BEp?x@=N<~CbLJBf6P)}vLS?xJXYJ2^<3KJUdrwKnJnTp{ zjIi|R=L7rn9b*D#Xxr4*R<3T5AuOS+#U8hNlfo&^9JO{VbH!v9^JbK=TCGR-5EWR@ zN8T-_I|&@A}(hKeL4_*eb!1G8p~&_Im8|wc>Cdir+gg90n1dw?QaXcx6Op_W1r=axRw>4;rM*UOpT#Eb9xU1IiWo@h?|5uP zka>-XW0Ikp@dIe;MN8B01a7+5V@h3WN{J=HJ*pe0uwQ3S&MyWFni47X32Q7SyCTNQ z+sR!_9IZa5!>f&V$`q!%H8ci!a|RMx5}5MA_kr+bhtQy{-^)(hCVa@I!^TV4RBi zAFa!Nsi3y37I5EK;0cqu|9MRj<^r&h1lF}u0KpKQD^5Y+LvFEwM zLU@@v4_Na#Axy6tn3P%sD^5P#<7F;sd$f4a7LBMk zGU^RZHBcxSA%kCx*eH&wgA?Qwazm8>9SCSz_!;MqY-QX<1@p$*T8lc?@`ikEqJ>#w zcG``^CoFMAhdEXT9qt47g0IZkaU)4R7wkGs^Ax}usqJ5HfDYAV$!=6?>J6+Ha1I<5 z|6=9soU4>E))tW$<#>F ziZ$6>KJf0bPfbx_)7-}tMINlc=}|H+$uX)mhC6-Hz+XZxsKd^b?RFB6et}O#+>Wmw9Ec9) z{q}XFWp{3@qmyK*Jvzpyqv57LIR;hPXKsrh{G?&dRjF%Zt5&m20Ll?OyfUYC3WRn{cgQ?^V~UAv+5 z&_m#&nIwffgX1*Z2#5^Kl4DbE#NrD&Hi4|7SPqZ}(>_+JMz=s|k77aEL}<=0Zfb)a z%F(*L3zCA<=xO)2U3B|pcTqDbBoFp>QyAEU(jMu8(jLA61-H!ucI804+B!$E^cQQa z)_ERrW3g!B9iLb3nn3dlkvD7KsY?sRvls3QC0qPi>o<)GHx%4Xb$5a3GBTJ(k@`e@ z$RUa^%S15^1oLEmA=sayrP5;9qtf!Z1*?e$ORVPsXpL{jL<6E)0sj&swP3}NPmR%FM?O>SQgN5XfHE< zo(4#Cv11(%Nnw_{_Ro}r6=gKd{k?NebJ~<~Kv0r(r0qe4n3LFx$5%x(BKvrz$m?LG zjLIc;hbj0FMdb9aH9Lpsof#yG$(0sG2%RL;d(n>;#jb!R_+dad+K;Ccw!|RY?uS(a zj~?=&M!4C(5LnlH6k%aYvz@7?xRa^2gml%vn&eKl$R_lJ+e|xsNfXzr#xuh(>`}9g zLHSyiFwK^-p!;p$yt7$F|3*IfO3Mlu9e>Dpx8O`37?fA`cj`C0B-m9uRhJjs^mRp# zWB;Aj6|G^1V6`jg7#7V9UFvnB4((nIwG?k%c7h`?0tS8J3Bn0t#pb#SA}N-|45$-j z$R>%7cc2ebAClXc(&0UtHX<>pd)akR3Kx_cK+n<}FhzmTx!8e9^u2e4%x{>T6pQ`6 zO182bh$-W5A3^wos0SV_TgPmF4WUP-+D25KjbC{y_6W_9I2_vNKwU(^qSdn&>^=*t z&uvp*@c8#2*paD!ZMCi3;K{Na;I4Q35zw$YrW5U@Kk~)&rw;G?d7Q&c9|x<Hg|CNMsxovmfth*|E*GHezPTWa^Hd^F4!B3sF;)? z(NaPyAhocu1jUe(!5Cy|dh|W2=!@fNmuNOzxi^tE_jAtzNJ0JR-avc_H|ve#KO}#S z#a(8secu|^Tx553d4r@3#6^MHbH)vmiBpn0X^29xEv!Vuh1n(Sr5I0V&`jA2;WS|Y zbf0e}X|)wA-Pf5gBZ>r4YX3Mav1kKY(ulAJ0Q*jB)YhviHK)w!TJsi3^dMa$L@^{` z_De`fF4;M87vM3Ph9SzCoCi$#Fsd38u!^0#*sPful^p5oI(xGU?yeYjn;Hq1!wzFk zG&2w}W3`AX4bxoVm03y>ts{KaDf!}b&7$(P4KAMP=vK5?1In^-YYNtx1f#}+2QK@h zeSeAI@E6Z8a?)>sZ`fbq9_snl6LCu6g>o)rO;ijp3|$vig+4t} zylEo7$SEW<_U+qgVcaVhk+4k+C9THI5V10qV*dOV6pPtAI$)QN{!JRBKh-D zk2^{j@bZ}yqW?<#VVuI_27*cI-V~sJiqQv&m07+10XF+#ZnIJdr8t`9s_EE;T2V;B z4UnQUH9EdX%zwh-5&wflY#ve!IWt0UE-My3?L#^Bh%kcgP1q{&26eXLn zTkjJ*w+(|_>Pq0v8{%nX$QZbf)tbJaLY$03;MO=Ic-uqYUmUCuXD>J>o6BCRF=xa% z3R4SK9#t1!K4I_d>tZgE>&+kZ?Q}1qo4&h%U$GfY058s%*=!kac{0Z+4Hwm!)pFLR zJ+5*OpgWUrm0FPI2ib4NPJ+Sk07j(`diti^i#kh&f}i>P4~|d?RFb#!JN)~D@)beox}bw?4VCf^y*`2{4`-@%SFTry2h z>9VBc9#JxEs1+0i2^LR@B1J`B9Ac=#FW=(?2;5;#U$0E0UNag_!jY$&2diQk_n)bT zl5Me_SUvqUjwCqmVcyb`igygB_4YUB*m$h5oeKv3uIF0sk}~es!{D>4r%PC*F~FN3owq5e0|YeUTSG#Vq%&Gk7uwW z0lDo#_wvflqHeRm*}l?}o;EILszBt|EW*zNPmq#?4A+&i0xx^?9obLyY4xx=Y9&^G;xYXYPxG)DOpPg!i_Ccl#3L}6xAAZzNhPK1XaC_~ z!A|mlo?Be*8Nn=a+FhgpOj@G7yYs(Qk(8&|h@_>w8Y^r&5nCqe0V60rRz?b5%J;GYeBqSAjo|K692GxD4` zRZyM2FdI+-jK2}WAZTZ()w_)V{n5tEb@>+JYluDozCb$fA4H)$bzg(Ux{*hXurjO^ zwAxc+UXu=&JV*E59}h3kzQPG4M)X8E*}#_&}w*KEgtX)cU{vm9b$atHa;s>| z+L6&cn8xUL*OSjx4YGjf6{Eq+Q3{!ZyhrL&^6Vz@jGbI%cAM9GkmFlamTbcQGvOlL zmJ?(FI)c86=JEs|*;?h~o)88>12nXlpMR4@yh%qdwFNpct;vMlc=;{FSo*apJ;p}! zAX~t;3tb~VuP|ZW;z$=IHf->F@Ml)&-&Bnb{iQyE#;GZ@C$PzEf6~q}4D>9jic@mTO5x76ulDz@+XAcm35!VSu zT*Gs>;f0b2TNpjU_BjHZ&S6Sqk6V1370+!eppV2H+FY!q*n=GHQ!9Rn6MjY!Jc77A zG7Y!lFp8?TIHN!LXO?gCnsYM-gQxsm=Ek**VmZu7vnuufD7K~GIxfxbsQ@qv2T zPa`tvHB$fFCyZl>3oYg?_wW)C>^_iDOc^B7klnTOoytQH18WkOk)L2BSD0r%xgRSW zQS9elF^?O=_@|58zKLK;(f77l-Zzu}4{fXed2saq!5k#UZAoDBqYQS{sn@j@Vtp|$ zG%gnZ$U|9@u#w1@11Sjl8ze^Co=)7yS(}=;68a3~g;NDe_X^}yJj;~s8xq9ahQ5_r zxAlTMnep*)w1e(TG%tWsjo3RR;yVGPEO4V{Zp?=a_0R#=V^ioQu4YL=BO4r0$$XTX zZfnw#_$V}sDAIDrezGQ+h?q24St0QNug_?{s-pI(^jg`#JRxM1YBV;a@@JQvH8*>> zIJvku74E0NlXkYe_624>znU0J@L<-c=G#F3k4A_)*;ky!C(^uZfj%WB3-*{*B$?9+ zDm$WFp=0(xnt6`vDQV3Jl5f&R(Mp};;q8d3I%Kn>Kx=^;uSVCw0L=gw53%Bp==8Sw zxtx=cs!^-_+i{2OK`Q;913+AXc_&Z5$@z3<)So0CU3;JAv=H?@Zpi~riQ{z-zLtVL z!oF<}@IgJp)Iyz1zVJ42!SPHSkjYNS4%ulVVIXdRuiZ@5Mx8LJS}J#qD^Zi_xQ@>DKDr-_e#>5h3dtje*NcwH_h;i{Sx7}dkdpuW z(yUCjckQsagv*QGMSi9u1`Z|V^}Wjf7B@q%j2DQXyd0nOyqg%m{CK_lAoKlJ7#8M} z%IvR?Vh$6aDWK2W!=i?*<77q&B8O&3?zP(Cs@kapc)&p7En?J;t-TX9abGT#H?TW? ztO5(lPKRuC7fs}zwcUKbRh=7E8wzTsa#Z{a`WR}?UZ%!HohN}d&xJ=JQhpO1PI#>X zHkb>pW04pU%Bj_mf~U}1F1=wxdBZu1790>3Dm44bQ#F=T4V3&HlOLsGH)+AK$cHk6 zia$=$kog?)07HCL*PI6}DRhpM^*%I*kHM<#1Se+AQ!!xyhcy6j7`iDX7Z-2i73_n# zas*?7LkxS-XSqv;YBa zW_n*32D(HTYQ0$feV_Fru1ZxW0g&iwqixPX3=9t4o)o|kOo79V$?$uh?#8Q8e>4e)V6;_(x&ViUVxma+i25qea;d-oK7ouuDsB^ab{ zu1qjQ%`n56VtxBE#0qAzb7lph`Eb-}TYpXB!H-}3Ykqyp`otprp7{VEuW*^IR2n$Fb99*nAtqT&oOFIf z@w*6>YvOGw@Ja?Pp1=whZqydzx@9X4n^2!n83C5{C?G@|E?&$?p*g68)kNvUTJ)I6 z1Q|(#UuP6pj78GUxq11m-GSszc+)X{C2eo-?8ud9sB=3(D47v?`JAa{V(IF zPZQ_0AY*9M97>Jf<o%#O_%Wq}8>YM=q0|tGY+hlXcpE=Z4Od z`NT7Hu2hnvRoqOw@g1f=bv`+nba{GwA$Ak0INlqI1k<9!x_!sL()h?hEWoWrdU3w` zZ%%)VR+Bc@_v!C#koM1p-3v_^L6)_Ktj4HE>aUh%2XZE@JFMOn)J~c`_7VWNb9c-N z2b|SZMR4Z@E7j&q&9(6H3yjEu6HV7{2!1t0lgizD;mZ9$r(r7W5G$ky@w(T_dFnOD z*p#+z$@pKE+>o@%eT(2-p_C}wbQ5s(%Sn_{$HDN@MB+Ev?t@3dPy`%TZ!z}AThZSu zN<1i$siJhXFdjV zP*y|V<`V8t=h#XTRUR~5`c`Z9^-`*BZf?WAehGdg)E2Je)hqFa!k{V(u+(hTf^Yq& zoruUh2(^3pe)2{bvt4&4Y9CY3js)PUHtd4rVG57}uFJL)D(JfSIo^{P=7liFXG zq5yqgof0V8paQcP!gy+;^pp-DA5pj=gbMN0eW=-eY+N8~y+G>t+x}oa!5r>tW$xhI zPQSv=pi;~653Gvf6~*JcQ%t1xOrH2l3Zy@8AoJ+wz@daW@m7?%LXkr!bw9GY@ns3e zSfuWF_gkWnesv?s3I`@}NgE2xwgs&rj?kH-FEy82=O8`+szN ziHch`vvS`zNfap14!&#i9H@wF7}yIPm=UB%(o(}F{wsZ(wA0nJ2aD^@B41>>o-_U6 zUqD~vdo48S8~FTb^+%#zcbQiiYoDKYcj&$#^;Smmb+Ljp(L=1Kt_J!;0s%1|JK}Wi z;={~oL!foo5n8=}rs6MmUW~R&;SIJO3TL4Ky?kh+b2rT9B1Jl4>#Uh-Bec z`Hsp<==#UEW6pGPhNk8H!!DUQR~#F9jEMI6T*OWfN^Ze&X(4nV$wa8QUJ>oTkruH# zm~O<`J7Wxseo@FqaZMl#Y(mrFW9AHM9Kb|XBMqaZ2a)DvJgYipkDD_VUF_PKd~dT7 z#02}bBfPn9a!X!O#83=lbJSK#E}K&yx-HI#T6ua)6o0{|={*HFusCkHzs|Fn&|C3H zBck1cmfcWVUN&i>X$YU^Sn6k2H;r3zuXbJFz)r5~3$d$tUj(l1?o={MM){kjgqXRO zc5R*#{;V7AQh|G|)jLM@wGAK&rm2~@{Pewv#06pHbKn#wL0P6F1!^qw9g&cW3Z=9} zj)POhOlwsh@eF=>z?#sIs*C-Nl(yU!#DaiaxhEs#iJqQ8w%(?+6lU02MYSeDkr!B- zPjMv+on6OLXgGnAtl(ao>|X2Y8*Hb}GRW5}-IzXnoo-d0!m4Vy$GS!XOLy>3_+UGs z2D|YcQx@M#M|}TDOetGi{9lGo9m-=0-^+nKE^*?$^uHkxZh}I{#UTQd;X!L+W@jm( zDg@N4+lUqI92o_rNk{3P>1gxAL=&O;x)ZT=q1mk0kLlE$WeWuY_$0`0jY-Kkt zP*|m3AF}Ubd=`<>(Xg0har*_@x2YH}bn0Wk*OZz3*e5;Zc;2uBdnl8?&XjupbkOeNZsNh6pvsq_ydmJI+*z**{I{0K)-;p1~k8cpJXL$^t!-`E}=*4G^-E8>H!LjTPxSx zcF+cS`ommfKMhNSbas^@YbTpH1*RFrBuATUR zt{oFWSk^$xU&kbFQ;MCX22RAN5F6eq9UfR$ut`Jw--p2YX)A*J69m^!oYfj2y7NYcH6&r+0~_sH^c^nzeN1AU4Ga7=FlR{S|Mm~MpzY0$Z+p2W(a={b-pR9EO1Rs zB%KY|@wLcAA@)KXi!d2_BxrkhDn`DT1=Dec}V!okd{$+wK z4E{n8R*xKyci1(CnNdhf$Dp2(Jpof0-0%-38X=Dd9PQgT+w%Lshx9+loPS~MOm%ZT zt%2B2iL_KU_ita%N>xjB!#71_3=3c}o zgeW~^U_ZTJQ2!PqXulQd=3b=XOQhwATK$y(9$#1jOQ4}4?~l#&nek)H(04f(Sr=s| zWv7Lu1=%WGk4FSw^;;!8&YPM)pQDCY9DhU`hMty1@sq1=Tj7bFsOOBZOFlpR`W>-J$-(kezWJj;`?x-v>ev{*8V z8p|KXJPV$HyQr1A(9LVrM47u-XpcrIyO`yWvx1pVYc&?154aneRpLqgx)EMvRaa#|9?Wwqs2+W8n5~79G z(}iCiLk;?enn}ew`HzhG+tu+Ru@T+K5juvZN)wY;x6HjvqD!&!)$$;1VAh~7fg0K| zEha#aN=Yv|3^~YFH}cc38ovVb%L|g@9W6fo(JtT6$fa?zf@Ct88e}m?i)b*Jgc{fl zExfdvw-BYDmH6>(4QMt#p0;FUIQqkhD}aH?a7)_%JtA~soqj{ppP_82yi9kaxuK>~ ze_)Zt>1?q=ZH*kF{1iq9sr*tVuy=u>Zev}!gEZx@O6-fjyu9X00gpIl-fS_pzjpqJ z1yqBmf9NF!jaF<+YxgH6oXBdK)sH(>VZ)1siyA$P<#KDt;8NT*l_0{xit~5j1P)FN zI8hhYKhQ)i z37^aP13B~u65?sg+_@2Kr^iWHN=U;EDSZ@2W2!5ALhGNWXnFBY%7W?1 z=HI9JzQ-pLKZDYTv<0-lt|6c-RwhxZ)mU2Os{bsX_i^@*fKUj8*aDO5pks=qn3Dv6 zwggpKLuyRCTVPwmw1r}B#AS}?X7b837UlXwp~E2|PJw2SGVueL7){Y&z!jL!XN=0i zU^Eig`S2`{+gU$68aRdWx?BZ{sU_f=8sn~>s~M?GU~`fH5kCc; z8ICp+INM3(3{#k32RZdv6b9MQYdZXNuk7ed8;G?S2nT+NZBG=Tar^KFl2SvhW$bGW#kdWL-I)s_IqVnCDDM9fm8g;P;8 z7t4yZn3^*NQfx7SwmkzP$=fwdC}bafQSEF@pd&P8@H#`swGy_rz;Z?Ty5mkS%>m#% zp_!m9e<()sfKiY(nF<1zBz&&`ZlJf6QLvLhl`_``%RW&{+O>Xhp;lwSsyRqGf=RWd zpftiR`={2(siiPAS|p}@q=NhVc0ELprt%=fMXO3B)4ryC2LT(o=sLM7hJC!}T1@)E zA3^J$3&1*M6Xq>03FX`R&w*NkrZE?FwU+Muut;>qNhj@bX17ZJxnOlPSZ=Zeiz~T_ zOu#yc3t6ONHB;?|r4w+pI)~KGN;HOGC)txxiUN8#mexj+W(cz%9a4sx|IRG=}ia zuEBuba3AHsV2feqw-3MvuL`I+2|`Ud4~7ZkN=JZ;L20|Oxna5vx1qbIh#k2O4$RQF zo`tL()zxaqibg^GbB+BS5#U{@K;WWQj~GcB1zb}zJkPwH|5hZ9iH2308!>_;%msji zJHSL~s)YHBR=Koa1mLEOHos*`gp=s8KA-C zu0aE+W!#iJ*0xqKm3A`fUGy#O+X+5W36myS>Uh2!R*s$aCU^`K&KKLCCDkejX2p=5 z%o7-fl03x`gaSNyr?3_JLv?2RLS3F*8ub>Jd@^Cc17)v8vYEK4aqo?OS@W9mt%ITJ z9=S2%R8M){CugT@k~~0x`}Vl!svYqX=E)c_oU6o}#Hb^%G1l3BudxA{F*tbjG;W_>=xV73pKY53v%>I)@D36I_@&p$h|Aw zonQS`07z_F#@T-%@-Tb|)7;;anoD_WH>9ewFy(ZcEOM$#Y)8>qi7rCnsH9GO-_7zF zu*C87{Df1P4TEOsnzZ@H%&lvV(3V@;Q!%+OYRp`g05PjY^gL$^$-t0Y>H*CDDs?FZly*oZ&dxvsxaUWF!{em4{A>n@vpXg$dwvt@_rgmHF z-MER`ABa8R-t_H*kv>}CzOpz;!>p^^9ztHMsHL|SRnS<-y5Z*r(_}c4=fXF`l^-i}>e7v!qs_jv zqvWhX^F=2sDNWA9c@P0?lUlr6ecrTKM%pNQ^?*Lq?p-0~?_j50xV%^(+H>sMul#Tw zeciF*1=?a7cI(}352%>LO96pD+?9!fNyl^9v3^v&Y4L)mNGK0FN43&Xf8jUlxW1Bw zyiu2;qW-aGNhs=zbuoxnxiwZ3{PFZM#Kw)9H@(hgX23h(`Wm~m4&TvoZoYp{plb^> z_#?vXcxd>r7K+1HKJvhed>gtK`TAbJUazUWQY6T~t2af%#<+Veyr%7-#*A#@&*;@g58{i|E%6yC_InGXCOd{L0;$)z#?n7M`re zh!kO{6=>7I?*}czyF7_frt#)s1CFJ_XE&VrDA?Dp3XbvF{qsEJgb&OLSNz_5g?HpK z9)8rsr4JN!Af3G9!#Qn(6zaUDqLN(g2g8*M)Djap?WMK9NKlkC)E2|-g|#-rp%!Gz zAHd%`iq|81efi93m3yTBw3g0j#;Yb2X{mhRAI?&KDmbGqou(2xiRNb^sV}%%Wu0?< z?($L>(#BO*)^)rSgyNRni$i`R4v;GhlCZ8$@e^ROX(p=2_v6Y!%^As zu022)fHdv_-~Yu_H6WVPLpHQx!W%^6j)cBhS`O3QBW#x(eX54d&I22op(N59b*&$v zFiSRY6rOc^(dgSV1>a7-5C;(5S5MvKcM2Jm-LD9TGqDpP097%52V+0>Xqq!! zq4e3vj53SE6i8J`XcQB|MZPP8j;PAOnpGnllH6#Ku~vS42xP*Nz@~y%db7Xi8s09P z1)e%8ys6&M8D=Dt6&t`iKG_4X=!kgRQoh%Z`dc&mlOUqXk-k`jKv9@(a^2-Upw>?< zt5*^DV~6Zedbec4NVl($2T{&b)zA@b#dUyd>`2JC0=xa_fIm8{5um zr-!ApXZhC8@=vC2WyxO|!@0Km)h8ep*`^he92$@YwP>VcdoS5OC^s38e#7RPsg4j+ zbVGG}WRSET&ZfrcR(x~k8n1rTP%CnfUNKUonD$P?FtNFF#cn!wEIab-;jU=B1dHK@ z(;(yAQJ`O$sMn>h;pf^8{JISW%d+@v6@CnXh9n5TXGC}?FI9i-D0OMaIg&mAg=0Kn zNJ7oz5*ReJukD55fUsMuaP+H4tDN&V9zfqF@ zr=#ecUk9wu{0;!+gl;3Bw=Vn^)z$ahVhhw)io!na&9}LmWurLb0zubxK=UEnU*{5P z+SP}&*(iBKSO4{alBHaY^)5Q=mZ+2OwIooJ7*Q5XJ+2|q`9#f?6myq!&oz?klihLq z4C)$XP!BNS0G_Z1&TM>?Jk{S~{F3n83ioli=IO6f%wkvCl(RFFw~j0tb{GvXTx>*sB0McY0s&SNvj4+^h`9nJ_wM>F!Uc>X}9PifQekn0sKI2SAJP!a4h z5cyGTuCj3ZBM^&{dRelIlT^9zcfaAuL5Y~bl!ppSf`wZbK$z#6U~rdclk``e+!qhe z6Qspo*%<)eu6?C;Bp<^VuW6JI|Ncvyn+LlSl;Mp22Bl7ARQ0Xc24%29(ZrdsIPw&-=yHQ7_Vle|5h>AST0 zUGX2Zk34vp?U~IHT|;$U86T+UUHl_NE4m|}>E~6q``7hccCaT^#y+?wD##Q%HwPd8 zV3x4L4|qqu`B$4(LXqDJngNy-{&@aFBvVsywt@X^}iH7P%>bR?ciC$I^U-4Foa`YKI^qDyGK7k%E%c_P=yzAi`YnxGA%DeNd++j3*h^ z=rn>oBd0|~lZ<6YvmkKY*ZJlJ;Im0tqgWu&E92eqt;+NYdxx`eS(4Hw_Jb5|yVvBg z*tbdY^!AN;luEyN4VRhS@-_DC{({ziH{&Z}iGElSV~qvT>L-8G%+yEL zX#MFOhj{InyKG=mvW-<1B@c-}x$vA(nU?>S>0*eN#!SLzQ)Ex7fvQ)S4D<8|I#N$3 zT5Ei`Z?cxBODHX8(Xp73v`IsAYC@9b;t}z0wxVuQSY1J^GRwDPN@qbM-ZF48T$GZ< z8WU+;Pqo?{ghI-KZ-i*ydXu`Ep0Xw^McH_KE9J0S7G;x8Fe`DVG?j3Pv=0YzJ}yZR z%2=oqHiUjvuk0~Ca>Kol4CFi0_xQT~;_F?=u+!kIDl-9g`#ZNZ9HCy17Ga1v^Jv9# z{T4Kb1-AzUxq*MutfOWWZgD*HnFfyYg0&e9f(5tZ>krPF6{VikNeHoc{linPPt#Si z&*g>(c54V8rT_AX!J&bNm-!umPvOR}vDai#`CX___J#=zeB*{4<&2WpaDncZsOkp* zsg<%@@rbrMkR_ux9?LsQxzoBa1s%$BBn6vk#{&&zUwcfzeCBJUwFYSF$08qDsB;gWQN*g!p8pxjofWbqNSZOEKOaTx@+* zwdt5*Q47@EOZ~EZL9s?1o?A%9TJT=Ob_13yyugvPg*e&ZU(r6^k4=2+D-@n=Hv5vu zSXG|hM(>h9^zn=eQ=$6`JO&70&2|%V5Lsx>)(%#;pcOfu>*nk_3HB_BNaH$`jM<^S zcSftDU1?nL;jy)+sfonQN}(}gUW?d_ikr*3=^{G)=tjBtEPe>TO|0ddVB zTklrSHiW+!#26frPXQQ(YN8DG$PZo?(po(QUCCf_OJC`pw*uey00%gmH!`WJkrKXj2!#6?`T25mTu9OJp2L8z3! z=arrL$ZqxuE{%yV)14Kd>k}j7pxZ6#$Dz8$@WV5p8kTqN<-7W)Q7Gt2{KoOPK_tZ| zf2WG~O5@{qPI+W<4f_;reuFVdO^5`ADC1!JQE|N`s3cq@(0WB!n0uh@*c{=LAd;~} zyGK@hbF-Oo+!nN)@i*O(`@FA#u?o=~e{`4O#5}z&=UkU*50fOrzi11D^&FOqe>wii z?*k+2|EcUs;Gx{!@KBT~>PAwLrIDT7Th=Utu?~?np@t^gFs?zgX=D${RwOY^WGh-+ z+#4$066ISh8eYW#FXWp~S`<*%O^ZuItL1Tyqt8#tZ zY120E;^VG`!lZn&3sPd$RkdHpU#|w+bYV)pJC|SH9g%|5IkxVTQcBA4CL0}$&}ef@ zW^Vtj%M;;_1xxP9x#ex17&4N*{ksO*_4O}xYu(p*JkL#yr}@7b)t5X?%CY<+s5_MJ zuiqt+N_;A(_)%lumoyRFixWa-M7qK_9s6<1X?JDa9fP!+_6u~~M$5L=ipB=7(j#f< zZ34J%=bs549%~_mA(|={uZNs_0?o7;-LBP(ZRnkd{-^|2|=4vUTmtByHL8 zEph`(LSEzQj68a+`d$V<45J7cyv^#|^|%fD#si1Nx!4NW*`l*{->HEWNh6-|g>-=r zXmQ|-i}Ku$ndUeHQ^&ieT!Lf}vf6GaqW9$DJ2NWrqwPY%%4nip$@vK$nRp*_C-v<| zuKz~ZyN&<%!NS26&x?jhy+@awJipMQ-8(X4#Ae5??U<1QMt1l9R=w9fAnEF}NYu$2 z>6}Vkc zIb*A?G*z8^IvibmBKn_u^5&T_1oey0gZS2~obf(#xk=erZGTEdQnt3DMGM+0oPwss zj5zXD;(oWhB_T@~Ig#9@v)AKtXu3>Inmgf@A|-lD-1U>cNyl3h?ADD9)GG4}zUGPk zZzaXe!~Kf?<~@$G?Uql3t8jy9{2!doq4=J}j9ktTxss{p6!9UdjyDERlA*xZ!=Q)KDs5O)phz>Vq3BNGoM(H|=1*Q4$^2fTZw z(%nq1P|5Rt81}SYJpEEzMPl5VJsV5&4e)ZWKDyoZ>1EwpkHx-AQVQc8%JMz;{H~p{=FXV>jIxvm4X*qv52e?Y-f%DJ zxEA165GikEASQ^fH6K#d!Tpu2HP{sFs%E=e$gYd$aj$+xue6N+Wc(rAz~wUsk2`(b z8Kvmyz%bKQxpP}~baG-rwYcYCvkHOi zlkR<=>ZBTU*8RF_d#Bl@zZsRIhx<%~Z@Z=ik z>adw3!DK(8R|q$vy{FTxw%#xliD~6qXmY^7_9kthVPTF~Xy1CfBqbU~?1QmxmU=+k z(ggxvEuA;0e&+ci-zQR{-f7aO{O(Pz_OsEjLh_K>MbvoZ4nxtk5u{g@nPv)cgW_R} z9}EA4K4@z0?7ue}Z(o~R(X&FjejUI2g~08PH1E4w>9o{)S(?1>Z0XMvTb|;&EuyOE zGvWNpYX)Nv<8|a^;1>bh#&znEcl-r!T#pn= z4$?Yudha6F%4b>*8@=BdtXXY4N+`U4Dmx$}>HeVJk-QdTG@t!tVT#0(LeV0gvqyyw z2sEp^9eY0N`u10Tm4n8No&A=)IeEC|gnmEXoNSzu!1<4R<%-9kY_8~5Ej?zRegMn78wuMs#;i&eUA0Zk_RXQ3b&TT} z;SCI=7-FUB@*&;8|n>(_g^HGf3@QODE3LpmX~ELnymQm{Sx9xrKS zK29p~?v@R$0=v6Dr5aW>-!{+h@?Q58|Kz8{{W`%J+lDAdb&M5VHrX_mDY;1-JLnf)ezmPau$)1;=`-FU=-r-83tX=C`S#}GZufju zQ>sXNT0Ny=k@nc%cFnvA_i4SC)?_ORXHq8B4D%el1uPX`c~uG#S1M7C+*MMqLw78E zhY2dI8@+N^qrMI1+;TUda(vGqGSRyU{Fnm`aqrr7bz42c5xsOO-~oZpkzorD1g}Y<6rk&3>PsSGy}W?MtqFky@A(X# zIuNZK0cK?^=;PUAu>j0#HtjbHCV*6?jzA&OoE$*Jlga*}LF`SF?WLhv1O|zqC<>*> zYB;#lsYKx0&kH@BFpW8n*yDcc6?;_zaJs<-jPSkCsSX-!aV=P5kUgF@Nu<{a%#K*F z134Q{9|YX7X(v$62_cY3^G%t~rD>Q0z@)1|zs)vjJ6Jq9;7#Ki`w+eS**En?7;n&7 zu==V3T&eFboN3ZiMx3D8qYc;VjFUk_H-WWCau(VFXSQf~viH0L$gwD$UfFHqNcgN`x}M+YQ6RnN<+@t>JUp#)9YOkqst-Ga?{FsDpEeX0(5v{0J~SEbWiL zXC2}M4?UH@u&|;%0y`eb33ldo4~z-x8zY!oVmV=c+f$m?RfDC35mdQ2E>Pze7KWP- z>!Bh<&57I+O_^s}9Tg^k)h7{xx@0a0IA~GAOt2yy!X%Q$1rt~LbTB6@Du!_0%HV>N zlf)QI1&gvERKwso23mJ!Ou6ZS#zCS5W`gxE5T>C#E|{i<1D35C222I33?Njaz`On7 zi<+VWFP6D{e-{yiN#M|Jgk<44u1TiMI78S5W`Sdb5f+{zu34s{CfWN7a3Cf^@L%!& zN$?|!!9j2c)j$~+R6n#891w-z8(!oBpL2K=+%a$r2|~8-(vQj5_XT`<0Ksf;oP+tz z9CObS!0m)Tgg`K#xBM8B(|Z)Wb&DYL{WTYv`;A=q6~Nnx2+!lTIXtj8J7dZE!P_{z z#f8w6F}^!?^KE#+ZDv+xd5O&3EmomZzsv?>E-~ygGum45fk!SBN&|eo1rKw^?aZJ4 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9f30b42..c42672d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Aug 07 21:55:31 IST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,115 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..5eed7ee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 40458040d9dc24bb589ebc87cd64760fae0aecbb Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 08:30:07 +0530 Subject: [PATCH 03/25] Resolve UI text at the call site, harden the clipboard, finish localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 1 and brings §2.5 forward, since both rewrite the same code. ViewModels resolved strings eagerly via UiText.StringResource(...).asString(appContext). Below API 33 - which is most of this app's supported range, minSdk being 28 - AppCompatDelegate.setApplicationLocales applies the locale override per Activity only, so the injected Application context keeps the *system* locale and every string resolved there came back in the previous language after a user switched language in Settings. That is a second root cause of "the app renders in English", independent of the translatable="false" bug fixed in 5.5.2. UiEvents.ShowSnackBar now carries UiText unresolved and the screens resolve it against LocalContext, which does honour the override on every API level. UiText gains a PluralResource variant, and its subtypes are now data classes holding List rather than a class with a vararg array field, so effects compare by value - identity comparison would have silently broken the effect assertions planned for the ViewModel tests. The Add/Edit bottom sheet title was a *resolved English string* stored in SavedStateHandle. It is now a boolean the UI turns into text. Clipboard moves out of the ViewModels into a CopyToClipboard effect handled by the UI, which is also where it belongs given Android 10+ only lets a focused app write the clipboard. The new SecureClipboard sets EXTRA_IS_SENSITIVE so Android 13+ redacts the paste preview, and clears the clip after 30s - but only if the clip is still the one we wrote, checked by ClipDescription timestamp, so we never wipe something the user copied from another app meanwhile. Auto-clear is best-effort by construction and is documented as such: a delayed clear that fires while backgrounded is a silent no-op on API 29+. Copying an entry *heading* is marked non-sensitive; only the secret itself is. On Android 13+ the app's own "copied" snackbar is suppressed because the system shows its own confirmation. PreviewViewModel and DetailViewModel no longer take an Application at all. Remaining localization work from the plan: - All 19 hardcoded contentDescriptions addressed. Actionable controls get localized strings; decorative branding images get null, because a screen reader announcing "App Icon" next to a visible title is noise. - Hardcoded "Password Settings", "Length: N", "Invalid file selected." and the "$score%" percentage extracted - percent placement is locale-dependent. - First plurals resource in the project, replacing a singular-only "used N times". Quantity classes follow CLDR per language rather than mirroring English: Arabic gets six forms, Russian four, Spanish/French/Italian/Portuguese three, and Japanese/Korean/Chinese only "other". - 15 new strings plus the plural translated into all 16 locales. - Added values-night: the only theme hardcoded windowLightStatusBar=true against a Light parent, so night mode showed dark status-bar icons on a dark bar and flashed a white window on cold start. windowLightStatusBar is now unset so enableEdgeToEdge's SystemBarStyle.auto owns bar contrast, and an explicit windowBackground is defined for both modes. res/values/colors.xml was an empty and now holds those. Lint caught a genuine defect during this work: context.getString inside a composable is not configuration-aware. Hoisted into composable scope. Verified: clean debug build, 16 unit tests, and lint all green; onboarding renders correctly in Hindi with no crash in both light and dark mode on an API 36 emulator. Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/viewModels/DetailViewModel.kt | 28 ++---- .../domain/viewModels/PreviewViewModel.kt | 28 ++---- .../domain/viewModels/SettingsViewModel.kt | 17 +--- .../screens/common/PasskeySearchBar.kt | 4 +- .../screens/detail_screen/DetailPage.kt | 37 ++++++-- .../components/DetailsBottomSheet.kt | 4 +- .../detail_screen/components/DetailsItem.kt | 6 +- .../components/PasswordSettingsSheet.kt | 6 +- .../screens/preview_screen/PreviewPage.kt | 39 ++++++-- .../preview_screen/components/PreviewItem.kt | 6 +- .../screens/security_screen/SecurityPage.kt | 8 +- .../screens/settings_screen/SettingsPage.kt | 15 +++- .../components/AnalysisBottomSheet.kt | 7 +- .../components/LanguageBottomSheet.kt | 2 +- .../screens/splash_screen/SplashPage.kt | 2 +- .../bhardwaj/passkey/utils/SecureClipboard.kt | 90 +++++++++++++++++++ .../com/bhardwaj/passkey/utils/UiEvents.kt | 22 ++++- .../java/com/bhardwaj/passkey/utils/UiText.kt | 59 ++++++++---- app/src/main/res/values-ar/strings.xml | 24 ++++- app/src/main/res/values-bn/strings.xml | 20 ++++- app/src/main/res/values-de/strings.xml | 20 ++++- app/src/main/res/values-es/strings.xml | 21 ++++- app/src/main/res/values-fr/strings.xml | 21 ++++- app/src/main/res/values-gu/strings.xml | 20 ++++- app/src/main/res/values-hi/strings.xml | 20 ++++- app/src/main/res/values-it/strings.xml | 21 ++++- app/src/main/res/values-ja/strings.xml | 19 +++- app/src/main/res/values-ko/strings.xml | 19 +++- app/src/main/res/values-mr/strings.xml | 20 ++++- app/src/main/res/values-night/colors.xml | 4 + app/src/main/res/values-night/themes.xml | 9 ++ app/src/main/res/values-pt/strings.xml | 21 ++++- app/src/main/res/values-ru/strings.xml | 22 ++++- app/src/main/res/values-ta/strings.xml | 20 ++++- app/src/main/res/values-te/strings.xml | 20 ++++- app/src/main/res/values-zh/strings.xml | 19 +++- app/src/main/res/values/colors.xml | 9 +- app/src/main/res/values/strings.xml | 28 +++++- app/src/main/res/values/themes.xml | 10 ++- 39 files changed, 633 insertions(+), 134 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/utils/SecureClipboard.kt create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/themes.xml diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt index e95cfad..8ab2f38 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt @@ -1,9 +1,5 @@ package com.bhardwaj.passkey.domain.viewModels -import android.app.Application -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -36,7 +32,6 @@ import javax.inject.Inject @HiltViewModel class DetailViewModel @Inject constructor( private val repository: PasskeyRepository, - private val appContext: Application, private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val _uiEvents = Channel() @@ -44,7 +39,8 @@ class DetailViewModel @Inject constructor( val detailTitle = savedStateHandle.getStateFlow(DETAIL_TITLE, "") val detailResponse = savedStateHandle.getStateFlow(DETAIL_RESPONSE, "") - val bottomSheetHeading = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, "") + /** true = editing an existing row, false = adding. Resolved to text by the UI. */ + val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) val previewId = savedStateHandle.get("previewId") ?: -1 private val _searchText = MutableStateFlow("") @@ -93,8 +89,7 @@ class DetailViewModel @Inject constructor( fun onEvent(event: DetailEvents) { when (event) { DetailEvents.OnAddDetailClick -> { - savedStateHandle[BOTTOM_SHEET_HEADING] = - UiText.StringResource(R.string.add).asString(context = appContext) + savedStateHandle[BOTTOM_SHEET_HEADING] = false isSheetOpen = true _searchText.value = "" } @@ -110,8 +105,7 @@ class DetailViewModel @Inject constructor( _searchText.value = "" repository.getDetailById(event.details.detailsId!!)?.let { detail -> isSheetOpen = true - savedStateHandle[BOTTOM_SHEET_HEADING] = - UiText.StringResource(R.string.edit).asString(context = appContext) + savedStateHandle[BOTTOM_SHEET_HEADING] = true savedStateHandle[DETAIL_TITLE] = event.details.question savedStateHandle[DETAIL_RESPONSE] = event.details.answer this@DetailViewModel.detail = detail @@ -136,7 +130,6 @@ class DetailViewModel @Inject constructor( sendUiEvents( UiEvents.ShowSnackBar( message = UiText.StringResource(R.string.enter_valid_title_n_response) - .asString(context = appContext) ) ) return@launch @@ -146,7 +139,6 @@ class DetailViewModel @Inject constructor( sendUiEvents( UiEvents.ShowSnackBar( message = UiText.StringResource(R.string.something_went_wrong) - .asString(context = appContext) ) ) return@launch @@ -177,17 +169,7 @@ class DetailViewModel @Inject constructor( } is DetailEvents.OnLongPress -> { - val clipboardManager = - appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = - ClipData.newPlainText("Text Copied Successfully.", event.detailsDescription) - clipboardManager.setPrimaryClip(clipData) - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.copied) - .asString(context = appContext) - ) - ) + sendUiEvents(UiEvents.CopyToClipboard(value = event.detailsDescription, isSensitive = true)) } is DetailEvents.OnSwipedLeft -> { diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt index 8265817..d695c53 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt @@ -1,9 +1,5 @@ package com.bhardwaj.passkey.domain.viewModels -import android.app.Application -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -36,7 +32,6 @@ import javax.inject.Inject @HiltViewModel class PreviewViewModel @Inject constructor( private val repository: PasskeyRepository, - private val appContext: Application, private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val _uiEvents = Channel() @@ -44,7 +39,8 @@ class PreviewViewModel @Inject constructor( val categoryName = savedStateHandle.getStateFlow(PREVIEW_CATEGORY_NAME, Categories.BANKS.name) val previewHeading = savedStateHandle.getStateFlow(PREVIEW_HEADING, "") - val bottomSheetHeading = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, "") + /** true = editing an existing row, false = adding. Resolved to text by the UI. */ + val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) private val _searchText = MutableStateFlow("") val searchText = _searchText.asStateFlow() @@ -82,8 +78,7 @@ class PreviewViewModel @Inject constructor( } PreviewEvents.OnAddPreviewClick -> { - savedStateHandle[BOTTOM_SHEET_HEADING] = - UiText.StringResource(R.string.add).asString(context = appContext) + savedStateHandle[BOTTOM_SHEET_HEADING] = false isSheetOpen = true _searchText.value = "" } @@ -98,8 +93,7 @@ class PreviewViewModel @Inject constructor( _searchText.value = "" repository.getPreviewById(event.preview.previewId!!)?.let { preview -> isSheetOpen = true - savedStateHandle[BOTTOM_SHEET_HEADING] = - UiText.StringResource(R.string.edit).asString(context = appContext) + savedStateHandle[BOTTOM_SHEET_HEADING] = true savedStateHandle[PREVIEW_HEADING] = event.preview.heading this@PreviewViewModel.preview = preview } @@ -117,7 +111,6 @@ class PreviewViewModel @Inject constructor( sendUiEvents( UiEvents.ShowSnackBar( message = UiText.StringResource(R.string.enter_valid_heading) - .asString(context = appContext) ) ) return@launch @@ -144,7 +137,6 @@ class PreviewViewModel @Inject constructor( sendUiEvents( UiEvents.ShowSnackBar( message = UiText.StringResource(R.string.heading_exists) - .asString(context = appContext) ) ) return@launch @@ -171,17 +163,7 @@ class PreviewViewModel @Inject constructor( } is PreviewEvents.OnLongPress -> { - val clipboardManager = - appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = - ClipData.newPlainText("Text Copied Successfully.", event.previewHeading) - clipboardManager.setPrimaryClip(clipData) - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.copied) - .asString(context = appContext) - ) - ) + sendUiEvents(UiEvents.CopyToClipboard(value = event.previewHeading, isSensitive = false)) } PreviewEvents.OnSettingsClick -> { diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt index 1c4f22a..4162032 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt @@ -118,7 +118,6 @@ class SettingsViewModel @Inject constructor( sendUiEvents( UiEvents.ShowSnackBar( message = UiText.StringResource(R.string.coming_soon) - .asString(context = appContext) ) ) } else { @@ -148,17 +147,13 @@ class SettingsViewModel @Inject constructor( if (exportDataToStorage(fileName)) { sendUiEvents( UiEvents.ShowSnackBar( - message = UiText.StringResource( - R.string.export_download - ).asString(appContext) + message = UiText.StringResource(R.string.export_download) ) ) } else { sendUiEvents( UiEvents.ShowSnackBar( - message = UiText.StringResource( - R.string.something_went_wrong - ).asString(appContext) + message = UiText.StringResource(R.string.something_went_wrong) ) ) } @@ -170,17 +165,13 @@ class SettingsViewModel @Inject constructor( if (importDateFromStorage(event.content)) { sendUiEvents( UiEvents.ShowSnackBar( - message = UiText.StringResource( - R.string.import_success - ).asString(appContext) + message = UiText.StringResource(R.string.import_success) ) ) } else { sendUiEvents( UiEvents.ShowSnackBar( - message = UiText.StringResource( - R.string.import_failed - ).asString(appContext) + message = UiText.StringResource(R.string.import_failed) ) ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/PasskeySearchBar.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/PasskeySearchBar.kt index 3602db9..2105784 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/PasskeySearchBar.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/PasskeySearchBar.kt @@ -50,7 +50,7 @@ fun PasskeySearchBar( ) { Icon( imageVector = Icons.Default.Search, - contentDescription = "Search Icon", + contentDescription = null, tint = MaterialTheme.colorScheme.outline ) @@ -89,7 +89,7 @@ fun PasskeySearchBar( interactionSource = remember { MutableInteractionSource() }, ), imageVector = Icons.Default.Close, - contentDescription = "Close Icon", + contentDescription = stringResource(R.string.cd_clear_search), tint = MaterialTheme.colorScheme.outline ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 9e3197a..8e90792 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -54,6 +55,9 @@ import com.bhardwaj.passkey.presentation.screens.detail_screen.components.Detail import com.bhardwaj.passkey.presentation.screens.detail_screen.components.PasswordSettingsSheet import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.utils.ButtonType +import com.bhardwaj.passkey.utils.asString +import com.bhardwaj.passkey.utils.SecureClipboard +import com.bhardwaj.passkey.utils.UiText import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableItem @@ -67,7 +71,10 @@ fun DetailScreen( ) { val detailTitle by viewModel.detailTitle.collectAsState() val detailResponse by viewModel.detailResponse.collectAsState() - val bottomSheetHeading by viewModel.bottomSheetHeading.collectAsState() + val isEditingSheet by viewModel.isEditingSheet.collectAsState() + // Resolved here rather than in the ViewModel so it follows the app locale. + val bottomSheetHeading = + stringResource(if (isEditingSheet) R.string.edit else R.string.add) val searchText by viewModel.searchText.collectAsState() val details by viewModel.details.collectAsState(initial = emptyList()) @@ -83,6 +90,8 @@ fun DetailScreen( } val isPasswordSettingsOpen = viewModel.isPasswordSettingsOpen + val context = LocalContext.current + LaunchedEffect(key1 = true) { viewModel.uiEvents.collect { event -> when (event) { @@ -90,11 +99,27 @@ fun DetailScreen( is UiEvents.ShowSnackBar -> { scope.launch { snackBarHostState.showSnackbar( - message = event.message, - actionLabel = event.action + message = event.message.asString(context), + actionLabel = event.action?.asString(context) ) } } + is UiEvents.CopyToClipboard -> { + SecureClipboard.copy( + context = context, + text = event.value, + isSensitive = event.isSensitive + ) + // Android 13+ shows its own copy confirmation, so a second one is noise. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + scope.launch { + snackBarHostState.showSnackbar( + message = UiText.StringResource(R.string.copied).asString(context) + ) + } + } + } + else -> Unit } @@ -113,7 +138,7 @@ fun DetailScreen( content = { Icon( imageVector = Icons.Default.Add, - contentDescription = "Add Detail", + contentDescription = stringResource(R.string.cd_add_detail), ) }, ) @@ -129,7 +154,7 @@ fun DetailScreen( Column(modifier = Modifier.fillMaxSize()) { Icon( painter = painterResource(id = R.drawable.icon_logo), - contentDescription = "App Icon", + contentDescription = null, tint = MaterialTheme.colorScheme.primary ) Text( @@ -201,7 +226,7 @@ fun DetailScreen( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Icon Delete", + contentDescription = stringResource(R.string.cd_delete), modifier = Modifier.align(Alignment.CenterEnd), tint = MaterialTheme.colorScheme.background ) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt index 5e0c616..6f49db2 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt @@ -131,7 +131,7 @@ fun DetailsBottomSheet( ) { Icon( imageVector = Icons.Default.Key, - contentDescription = "Refresh", + contentDescription = stringResource(R.string.cd_generate_password), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -148,7 +148,7 @@ fun DetailsBottomSheet( ) { Icon( imageVector = Icons.Default.Settings, - contentDescription = "Settings", + contentDescription = stringResource(R.string.cd_password_options), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt index e4cfa15..f7b6ff6 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView @@ -31,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bhardwaj.passkey.R import com.bhardwaj.passkey.data.local.entity.Details import com.bhardwaj.passkey.domain.events.DetailEvents import com.bhardwaj.passkey.presentation.theme.BebasNeue @@ -83,7 +85,7 @@ fun DetailsItem( } } ?: Modifier, imageVector = Icons.Default.Apps, - contentDescription = "Icon Drag", + contentDescription = stringResource(R.string.cd_drag_handle), tint = MaterialTheme.colorScheme.primary ) Text( @@ -115,7 +117,7 @@ fun DetailsItem( interactionSource = remember { MutableInteractionSource() } ), imageVector = Icons.Default.Edit, - contentDescription = "Icon Edit", + contentDescription = stringResource(R.string.cd_edit), tint = MaterialTheme.colorScheme.primary ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt index add7399..339eeee 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt @@ -20,10 +20,12 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins @@ -55,7 +57,7 @@ fun PasswordSettingsSheet( .padding(bottom = 32.dp) ) { Text( - text = "Password Settings", + text = stringResource(R.string.password_settings), fontFamily = BebasNeue, fontSize = 32.sp, color = MaterialTheme.colorScheme.secondary, @@ -63,7 +65,7 @@ fun PasswordSettingsSheet( ) Text( - text = "Length: ${length.toInt()}", + text = stringResource(R.string.password_length, length.toInt()), fontFamily = Poppins, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index 5e48793..11304b4 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -48,6 +48,7 @@ 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.LocalContext import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -66,6 +67,9 @@ import com.bhardwaj.passkey.presentation.screens.preview_screen.components.Previ import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.utils.ButtonType import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.utils.asString +import com.bhardwaj.passkey.utils.SecureClipboard +import com.bhardwaj.passkey.utils.UiText import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableItem @@ -83,7 +87,10 @@ fun PreviewScreen( val categoryNameMap = Categories.entries.associate { it.name to stringResource(it.labelRes) } val previewHeading by viewModel.previewHeading.collectAsState() - val bottomSheetHeading by viewModel.bottomSheetHeading.collectAsState() + val isEditingSheet by viewModel.isEditingSheet.collectAsState() + // Resolved here rather than in the ViewModel so it follows the app locale. + val bottomSheetHeading = + stringResource(if (isEditingSheet) R.string.edit else R.string.add) val searchText by viewModel.searchText.collectAsState() val previews by viewModel.previews.collectAsState() @@ -100,6 +107,8 @@ fun PreviewScreen( } } + val context = LocalContext.current + LaunchedEffect(key1 = true) { viewModel.uiEvents.collect { event -> when (event) { @@ -107,11 +116,27 @@ fun PreviewScreen( is UiEvents.ShowSnackBar -> { scope.launch { snackBarHostState.showSnackbar( - message = event.message, - actionLabel = event.action + message = event.message.asString(context), + actionLabel = event.action?.asString(context) ) } } + is UiEvents.CopyToClipboard -> { + SecureClipboard.copy( + context = context, + text = event.value, + isSensitive = event.isSensitive + ) + // Android 13+ shows its own copy confirmation, so a second one is noise. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + scope.launch { + snackBarHostState.showSnackbar( + message = UiText.StringResource(R.string.copied).asString(context) + ) + } + } + } + else -> Unit } @@ -143,7 +168,7 @@ fun PreviewScreen( content = { Icon( imageVector = Icons.Default.Add, - contentDescription = "Add Preview", + contentDescription = stringResource(R.string.cd_add_entry), ) } ) @@ -164,12 +189,12 @@ fun PreviewScreen( ) { Icon( painter = painterResource(id = R.drawable.icon_logo), - contentDescription = "App Icon", + contentDescription = null, tint = MaterialTheme.colorScheme.primary ) Icon( imageVector = Icons.Default.Settings, - contentDescription = "Settings Icon", + contentDescription = stringResource(R.string.cd_settings), tint = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(8.dp) @@ -253,7 +278,7 @@ fun PreviewScreen( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Icon Delete", + contentDescription = stringResource(R.string.cd_delete), modifier = Modifier.align(Alignment.CenterEnd), tint = MaterialTheme.colorScheme.background ) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt index ffc9739..4e6b710 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt @@ -23,12 +23,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bhardwaj.passkey.R import com.bhardwaj.passkey.domain.events.PreviewEvents import com.bhardwaj.passkey.presentation.theme.Poppins import sh.calvin.reorderable.ReorderableCollectionItemScope @@ -76,7 +78,7 @@ fun PreviewItem( } } ?: Modifier, imageVector = Icons.Default.Apps, - contentDescription = "Icon Drag", + contentDescription = stringResource(R.string.cd_drag_handle), tint = MaterialTheme.colorScheme.primary ) Text( @@ -110,7 +112,7 @@ fun PreviewItem( interactionSource = remember { MutableInteractionSource() } ), imageVector = Icons.Default.Edit, - contentDescription = "Icon Edit", + contentDescription = stringResource(R.string.cd_edit), tint = MaterialTheme.colorScheme.primary ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt index 2113a7b..314207e 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt @@ -53,6 +53,7 @@ import com.bhardwaj.passkey.R import com.bhardwaj.passkey.domain.events.SecurityEvents import com.bhardwaj.passkey.domain.viewModels.SecurityViewModel import com.bhardwaj.passkey.presentation.theme.Poppins +import com.bhardwaj.passkey.utils.asString import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -65,6 +66,8 @@ fun SecurityScreen( val snackBarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val context = LocalContext.current + LaunchedEffect(key1 = true) { viewModel.uiEvents.collect { event -> when (event) { @@ -76,8 +79,8 @@ fun SecurityScreen( is UiEvents.ShowSnackBar -> { scope.launch { snackBarHostState.showSnackbar( - message = event.message, - actionLabel = event.action + message = event.message.asString(context), + actionLabel = event.action?.asString(context) ) } } @@ -88,7 +91,6 @@ fun SecurityScreen( } val activity = LocalActivity.current as FragmentActivity - val context = LocalContext.current val startForResult = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == FragmentActivity.RESULT_CANCELED) { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index fa2f81c..2335c78 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -49,6 +49,7 @@ import com.bhardwaj.passkey.presentation.screens.settings_screen.components.Sett import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins import com.bhardwaj.passkey.utils.Constants.Companion.FILE_TYPE +import com.bhardwaj.passkey.utils.asString import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.launch @@ -64,6 +65,8 @@ fun SettingsScreen( val scaffoldState = rememberBottomSheetScaffoldState() val context = LocalContext.current + val invalidFileMessage = stringResource(R.string.invalid_file_selected) + val importDataLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { document -> @@ -75,7 +78,9 @@ fun SettingsScreen( } } else { scope.launch { - snackBarHostState.showSnackbar(message = "Invalid file selected.") + snackBarHostState.showSnackbar( + message = invalidFileMessage + ) } } } @@ -92,11 +97,13 @@ fun SettingsScreen( is UiEvents.ShowSnackBar -> { scope.launch { snackBarHostState.showSnackbar( - message = event.message, - actionLabel = event.action + message = event.message.asString(context), + actionLabel = event.action?.asString(context) ) } } + + else -> Unit } } } @@ -154,7 +161,7 @@ fun SettingsScreen( ) { Icon( painter = painterResource(id = R.drawable.icon_logo), - contentDescription = "App Icon", + contentDescription = null, tint = MaterialTheme.colorScheme.primary ) Text( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt index bd0c688..d736d32 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -202,7 +203,7 @@ fun ScoreCircularIndicator( } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "$score%", + text = stringResource(R.string.security_score_percent, score), fontSize = 28.sp, fontWeight = FontWeight.Bold, fontFamily = Poppins, @@ -281,7 +282,7 @@ fun SimpleAnalysisItem(detail: Details, onClick: () -> Unit) { } Icon( imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = "Open", + contentDescription = stringResource(R.string.cd_open), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp) ) @@ -307,7 +308,7 @@ fun ReusedAnalysisItem( modifier = Modifier.padding(bottom = 8.dp) ) { Text( - text = stringResource(R.string.used_times, count), + text = pluralStringResource(R.plurals.password_used_times, count, count), fontFamily = Poppins, fontWeight = FontWeight.Bold, fontSize = 14.sp, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt index 56bc036..26eebc8 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt @@ -182,7 +182,7 @@ fun LanguageBottomSheet( onClick = { onBackIconClick() } ), imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back Icon" + contentDescription = stringResource(R.string.cd_back) ) Text( modifier = Modifier.padding(start = 16.dp), diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt index a62715d..af58f46 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt @@ -77,7 +77,7 @@ fun SplashPage( ) { Image( painter = painterResource(id = R.drawable.splash_logo), - contentDescription = "App Logo", + contentDescription = null, modifier = Modifier .fillMaxSize() .scale(scale.value) diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/SecureClipboard.kt b/app/src/main/java/com/bhardwaj/passkey/utils/SecureClipboard.kt new file mode 100644 index 0000000..a40d2fd --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/utils/SecureClipboard.kt @@ -0,0 +1,90 @@ +package com.bhardwaj.passkey.utils + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import androidx.core.content.getSystemService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Clipboard access hardened for secret values. + * + * Two problems with the previous inline `ClipData.newPlainText(...)` calls: + * - the clip carried no sensitivity flag, so Android 13+ rendered the copied password in the + * system paste preview; + * - nothing ever cleared it, so a password sat on the clipboard indefinitely. + * + * Auto-clear is best-effort by design. Since API 29 an app without window focus cannot write + * the clipboard, so a delayed clear that fires while backgrounded is a silent no-op. The real + * defences are [EXTRA_IS_SENSITIVE] and Android 12+'s own system-wide clipboard expiry; the + * timer just shortens the window while the user is still in the app. + */ +object SecureClipboard { + + /** + * Literal rather than [ClipDescription.EXTRA_IS_SENSITIVE] (API 33) on purpose: the same key + * is honoured by Gboard and several OEM clipboard managers below 33, and is a harmless no-op + * everywhere else. + */ + private const val EXTRA_IS_SENSITIVE = "android.content.extra.IS_SENSITIVE" + + private const val CLIP_LABEL = "PassKey" + const val DEFAULT_AUTO_CLEAR_MILLIS = 30_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var clearJob: Job? = null + + /** Timestamp of the clip we last wrote, so we never wipe something the user copied since. */ + private var ourClipTimestamp: Long = 0L + + fun copy( + context: Context, + text: String, + isSensitive: Boolean, + autoClearMillis: Long = if (isSensitive) DEFAULT_AUTO_CLEAR_MILLIS else 0L + ) { + val clipboard = context.getSystemService() ?: return + + val clip = ClipData.newPlainText(CLIP_LABEL, text) + if (isSensitive) { + clip.description.extras = PersistableBundle().apply { + putBoolean(EXTRA_IS_SENSITIVE, true) + } + } + clipboard.setPrimaryClip(clip) + ourClipTimestamp = clipboard.primaryClipDescription?.timestamp ?: 0L + + clearJob?.cancel() + if (autoClearMillis > 0L) { + clearJob = scope.launch { + delay(autoClearMillis) + clearIfOurs(context) + } + } + } + + /** Clears the clipboard only if it still holds the clip this object wrote. */ + fun clearIfOurs(context: Context) { + val clipboard = context.getSystemService() ?: return + val description = clipboard.primaryClipDescription ?: return + if (description.label != CLIP_LABEL) return + if (ourClipTimestamp != 0L && description.timestamp != ourClipTimestamp) return + + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + clipboard.clearPrimaryClip() + } else { + clipboard.setPrimaryClip(ClipData.newPlainText("", "")) + } + } + ourClipTimestamp = 0L + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt b/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt index 47bd8ca..599fcd2 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt @@ -2,9 +2,25 @@ package com.bhardwaj.passkey.utils sealed interface UiEvents { data object PopBackStack : UiEvents + data class Navigate(val route: String) : UiEvents + + /** + * Carries [UiText] rather than a resolved String so the message is localized at the call + * site, using the Activity context that actually honours the per-app locale. + */ data class ShowSnackBar( - val message: String, - val action: String? = null + val message: UiText, + val action: UiText? = null + ) : UiEvents + + /** + * Copying is a UI concern and needs an Activity context to satisfy Android 10+'s rule that + * only a focused app may write the clipboard, so it is an effect rather than something the + * ViewModel does itself. + */ + data class CopyToClipboard( + val value: String, + val isSensitive: Boolean ) : UiEvents -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/UiText.kt b/app/src/main/java/com/bhardwaj/passkey/utils/UiText.kt index ff640fa..769c94f 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/UiText.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/UiText.kt @@ -1,26 +1,51 @@ package com.bhardwaj.passkey.utils import android.content.Context +import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -sealed class UiText { - data class DynamicString(val value: String) : UiText() - class StringResource(@StringRes val resId: Int, vararg val args: Any) : UiText() +/** + * A piece of user-facing text that is resolved as late as possible. + * + * ViewModels emit these unresolved. Resolving in a ViewModel means resolving against the + * Application context, whose Resources do not track the per-app locale override applied by + * [androidx.appcompat.app.AppCompatDelegate.setApplicationLocales] below API 33 - so on this + * app's minSdk 28..32 range every such string came back in the wrong language after the user + * switched language in Settings. + * + * These are data classes on purpose: tests assert on emitted effects by value, which an + * identity-based class (or a `vararg` array field) would silently break. + */ +sealed interface UiText { - fun asString(context: Context): String { - return when (this) { - is DynamicString -> value - is StringResource -> context.getString(resId, *args) - } - } + data class DynamicString(val value: String) : UiText - @Composable - fun asString(): String { - return when (this) { - is DynamicString -> value - is StringResource -> stringResource(resId, *args) - } - } -} \ No newline at end of file + data class StringResource( + @param:StringRes val resId: Int, + val args: List = emptyList() + ) : UiText + + data class PluralResource( + @param:PluralsRes val resId: Int, + val count: Int, + val args: List = emptyList() + ) : UiText +} + +/** Resolves against [context]. Prefer the `@Composable` overload inside composition. */ +fun UiText.asString(context: Context): String = when (this) { + is UiText.DynamicString -> value + is UiText.StringResource -> context.getString(resId, *args.toTypedArray()) + is UiText.PluralResource -> + context.resources.getQuantityString(resId, count, *args.toTypedArray()) +} + +@Composable +fun UiText.asString(): String = when (this) { + is UiText.DynamicString -> value + is UiText.StringResource -> stringResource(resId, *args.toTypedArray()) + is UiText.PluralResource -> pluralStringResource(resId, count, *args.toTypedArray()) +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4ed2f7c..3cf43db 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -79,6 +79,28 @@ ممتاز يحتاج إلى انتباه حرج - مستخدمة %1$d مرات لم يتم العثور على مشاكل. أحسنت! + إعادة ترتيب + تعديل + حذف + إضافة تفصيل + إضافة إدخال + الإعدادات + إنشاء كلمة مرور + خيارات كلمة المرور + مسح البحث + فتح + رجوع + إعدادات كلمة المرور + الطول: %1$d + %1$d%% + الملف المحدد غير صالح. + + لم تُستخدم + استُخدمت مرة واحدة + استُخدمت مرتين + استُخدمت %1$d مرات + استُخدمت %1$d مرة + استُخدمت %1$d مرة + \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 6a115fb..fa41bb1 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -79,6 +79,24 @@ চমৎকার মনোযোগ প্রয়োজন জটিল - %1$d বার ব্যবহৃত হয়েছে কোনো সমস্যা পাওয়া যায়নি। দারুণ! + পুনরায় সাজান + সম্পাদনা + মুছুন + বিবরণ যোগ করুন + এন্ট্রি যোগ করুন + সেটিংস + পাসওয়ার্ড তৈরি করুন + পাসওয়ার্ড বিকল্প + অনুসন্ধান মুছুন + খুলুন + ফিরে যান + পাসওয়ার্ড সেটিংস + দৈর্ঘ্য: %1$d + %1$d%% + অবৈধ ফাইল নির্বাচিত হয়েছে। + + %1$d বার ব্যবহৃত + %1$d বার ব্যবহৃত + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8e413f5..8245b44 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -79,6 +79,24 @@ Ausgezeichnet Aufmerksamkeit erforderlich Kritisch - %1$d mal verwendet Keine Probleme gefunden. Gut gemacht! + Neu anordnen + Bearbeiten + Löschen + Detail hinzufügen + Eintrag hinzufügen + Einstellungen + Passwort generieren + Passwortoptionen + Suche löschen + Öffnen + Zurück + Passworteinstellungen + Länge: %1$d + %1$d %% + Ungültige Datei ausgewählt. + + %1$d Mal verwendet + %1$d Mal verwendet + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 447b9ee..ffff14a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -79,6 +79,25 @@ Excelente Necesita atención Crítico - Usado %1$d veces No se encontraron problemas. ¡Bien hecho! + Reordenar + Editar + Eliminar + Añadir detalle + Añadir entrada + Ajustes + Generar contraseña + Opciones de contraseña + Borrar búsqueda + Abrir + Atrás + Ajustes de contraseña + Longitud: %1$d + %1$d %% + Archivo seleccionado no válido. + + Usada %1$d vez + Usada %1$d veces + Usada %1$d veces + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f95faa4..50024fb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -79,6 +79,25 @@ Excellent Nécessite une attention Critique - Utilisé %1$d fois Aucun problème trouvé. Bien joué ! + Réorganiser + Modifier + Supprimer + Ajouter un détail + Ajouter une entrée + Paramètres + Générer un mot de passe + Options du mot de passe + Effacer la recherche + Ouvrir + Retour + Paramètres du mot de passe + Longueur : %1$d + %1$d %% + Fichier sélectionné non valide. + + Utilisé %1$d fois + Utilisé %1$d fois + Utilisé %1$d fois + diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index 5e7bedd..d82a7f6 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -79,6 +79,24 @@ ઉત્તમ ધ્યાન આપવાની જરૂર છે ગંભીર - %1$d વખત ઉપયોગ થયો કોઈ સમસ્યા મળી નથી. સરસ! + ફરીથી ગોઠવો + સંપાદિત કરો + કાઢી નાખો + વિગત ઉમેરો + એન્ટ્રી ઉમેરો + સેટિંગ્સ + પાસવર્ડ બનાવો + પાસવર્ડ વિકલ્પો + શોધ સાફ કરો + ખોલો + પાછળ + પાસવર્ડ સેટિંગ્સ + લંબાઈ: %1$d + %1$d%% + અમાન્ય ફાઇલ પસંદ કરી. + + %1$d વાર વપરાયેલ + %1$d વાર વપરાયેલ + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 22e3195..e1f5981 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -79,6 +79,24 @@ बहुत बढ़िया ध्यान देने की आवश्यकता है गंभीर - %1$d बार उपयोग किया गया कोई समस्या नहीं मिली। बहुत बढ़िया! + क्रम बदलें + संपादित करें + हटाएँ + विवरण जोड़ें + प्रविष्टि जोड़ें + सेटिंग्स + पासवर्ड बनाएँ + पासवर्ड विकल्प + खोज साफ़ करें + खोलें + वापस + पासवर्ड सेटिंग्स + लंबाई: %1$d + %1$d%% + अमान्य फ़ाइल चुनी गई। + + %1$d बार इस्तेमाल हुआ + %1$d बार इस्तेमाल हुआ + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d80980e..f80bc96 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -79,6 +79,25 @@ Eccellente Richiede Attenzione Critico - Usata %1$d volte Nessun problema trovato. Ottimo lavoro! + Riordina + Modifica + Elimina + Aggiungi dettaglio + Aggiungi voce + Impostazioni + Genera password + Opzioni password + Cancella ricerca + Apri + Indietro + Impostazioni password + Lunghezza: %1$d + %1$d%% + File selezionato non valido. + + Usata %1$d volta + Usata %1$d volte + Usata %1$d volte + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 668931f..994c02e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -79,6 +79,23 @@ 優秀 注意が必要 危険 - %1$d 回使用 問題は見つかりませんでした。素晴らしい! + 並べ替え + 編集 + 削除 + 詳細を追加 + 項目を追加 + 設定 + パスワードを生成 + パスワードのオプション + 検索をクリア + 開く + 戻る + パスワード設定 + 長さ: %1$d + %1$d%% + 選択したファイルは無効です。 + + %1$d 回使用 + \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 80f19c6..e05c9ad 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -79,6 +79,23 @@ 훌륭함 주의 필요 심각함 - %1$d회 사용됨 문제가 발견되지 않았습니다. 잘하셨습니다! + 순서 변경 + 편집 + 삭제 + 세부 정보 추가 + 항목 추가 + 설정 + 비밀번호 생성 + 비밀번호 옵션 + 검색 지우기 + 열기 + 뒤로 + 비밀번호 설정 + 길이: %1$d + %1$d%% + 잘못된 파일을 선택했습니다. + + %1$d회 사용됨 + \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 657cd85..3a2d924 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -79,6 +79,24 @@ उत्कृष्ट लक्ष देणे आवश्यक आहे गंभीर - %1$d वेळा वापरले कोणतीही समस्या आढळली नाही. छान! + क्रम बदला + संपादित करा + हटवा + तपशील जोडा + नोंद जोडा + सेटिंग्ज + पासवर्ड तयार करा + पासवर्ड पर्याय + शोध साफ करा + उघडा + मागे + पासवर्ड सेटिंग्ज + लांबी: %1$d + %1$d%% + अवैध फाइल निवडली. + + %1$d वेळा वापरले + %1$d वेळा वापरले + \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..d05ab98 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #1C1B1F + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..cea49f0 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f39b7b3..ff05a74 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -79,6 +79,25 @@ Excelente Requer Atenção Crítico - Usado %1$d vezes Nenhum problema encontrado. Bom trabalho! + Reordenar + Editar + Excluir + Adicionar detalhe + Adicionar item + Configurações + Gerar senha + Opções de senha + Limpar busca + Abrir + Voltar + Configurações de senha + Comprimento: %1$d + %1$d%% + Arquivo selecionado inválido. + + Usada %1$d vez + Usada %1$d vezes + Usada %1$d vezes + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a2e5db9..3e4ae23 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -79,6 +79,26 @@ Отлично Требует внимания Критично - Использовано %1$d раз Проблем не обнаружено. Отличная работа! + Изменить порядок + Изменить + Удалить + Добавить деталь + Добавить запись + Настройки + Создать пароль + Параметры пароля + Очистить поиск + Открыть + Назад + Настройки пароля + Длина: %1$d + %1$d %% + Выбран недопустимый файл. + + Использован %1$d раз + Использован %1$d раза + Использован %1$d раз + Использован %1$d раза + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index aafd990..a82c42d 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -79,6 +79,24 @@ சிறந்தது கவனம் தேவை மிக முக்கியம் - %1$d முறை பயன்படுத்தப்பட்டது எந்த பிரச்சனையும் இல்லை. நன்று! + மறுவரிசைப்படுத்து + திருத்து + நீக்கு + விவரம் சேர் + உள்ளீடு சேர் + அமைப்புகள் + கடவுச்சொல் உருவாக்கு + கடவுச்சொல் விருப்பங்கள் + தேடலை அழி + திற + பின்செல் + கடவுச்சொல் அமைப்புகள் + நீளம்: %1$d + %1$d%% + தவறான கோப்பு தேர்ந்தெடுக்கப்பட்டது. + + %1$d முறை பயன்படுத்தப்பட்டது + %1$d முறை பயன்படுத்தப்பட்டது + diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index ea7126f..d3c1c77 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -79,7 +79,25 @@ అద్భుతం శ్రద్ధ అవసరం క్లిష్టమైన - %1$d సార్లు ఉపయోగించబడింది ఎటువంటి సమస్యలు కనుగొనబడలేదు. బాగా చేసారు! + క్రమం మార్చు + సవరించు + తొలగించు + వివరం జోడించు + ఎంట్రీ జోడించు + సెట్టింగ్‌లు + పాస్‌వర్డ్ సృష్టించు + పాస్‌వర్డ్ ఎంపికలు + శోధనను తొలగించు + తెరువు + వెనుకకు + పాస్‌వర్డ్ సెట్టింగ్‌లు + పొడవు: %1$d + %1$d%% + చెల్లని ఫైల్ ఎంపిక చేయబడింది. + + %1$d సారి ఉపయోగించబడింది + %1$d సార్లు ఉపయోగించబడింది + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index d6594a0..41a38a1 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -79,6 +79,23 @@ 优秀 需要注意 严重 - 已使用 %1$d 次 未发现问题。干得好! + 重新排序 + 编辑 + 删除 + 添加详情 + 添加条目 + 设置 + 生成密码 + 密码选项 + 清除搜索 + 打开 + 返回 + 密码设置 + 长度:%1$d + %1$d%% + 选择的文件无效。 + + 已使用 %1$d 次 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3a09068..1187391 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,2 +1,9 @@ - \ No newline at end of file + + + #FFFBFE + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9da5e95..c68b6ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,7 +100,33 @@ Excellent Needs Attention Critical - Used %1$d times No issues found. Good job! + + + Reorder + Edit + Delete + Add detail + Add entry + Settings + Generate password + Password options + Clear search + Open + Back + Password settings + Length: %1$d + %1$d%% + Invalid file selected. + + + + Used %1$d time + Used %1$d times + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c43cdf5..c412d4d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,6 +4,12 @@ - \ No newline at end of file + From 0519798ed108ba0f7cb7e5c6dde1bbb27fa51b7c Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 08:32:42 +0530 Subject: [PATCH 04/25] Secure sheets and dialogs, keep secrets off disk, add DB transactions Three pieces of security hygiene plus the repository support Phase 3 needs. FLAG_SECURE was set on the Activity window only. Compose Dialog and ModalBottomSheet are separate windows: under the currently resolved Compose versions their securePolicy defaults to SecureFlagPolicy.Inherit, so they do inherit it today and this was a latent risk rather than an active leak - but it breaks silently if a default changes or a sheet is ever hosted off a non-secure window, and the password-analysis sheet renders weak and reused passwords. All five ModalBottomSheets and all three dialogs now pin SecureFlagPolicy.SecureOn explicitly rather than relying on inheritance. DetailViewModel kept the in-progress entry title and answer in SavedStateHandle, including freshly generated passwords. SavedStateHandle is serialized into the saved-instance-state bundle, which the system persists to disk under /data/system_ce// - so cleartext secrets were being written outside the encrypted database. Both move to plain in-memory MutableStateFlows. The trade is that a half-typed entry is lost to process death, which is the right side of that trade for a password manager. The Add/Edit flag stays in SavedStateHandle, being a boolean rather than vault content. Added PasskeyRepository.runInTransaction and deleteAll, backed by Room's withTransaction, and used them immediately to fix reordering: both Preview and Detail reorder wrote N sequence updates in a bare loop, so an interruption left the list partially renumbered. Phase 3's import needs the same primitive to stop a malformed row leaving a half-populated vault. Co-Authored-By: Claude Opus 5 (1M context) --- .../passkey/data/local/dao/DetailsDao.kt | 4 ++ .../passkey/data/local/dao/PreviewDao.kt | 3 ++ .../data/repository/PassKeyRepository.kt | 14 ++++++ .../java/com/bhardwaj/passkey/di/AppModule.kt | 2 +- .../repository/PasskeyRepositoryImpl.kt | 11 +++++ .../domain/viewModels/DetailViewModel.kt | 48 +++++++++++-------- .../domain/viewModels/PreviewViewModel.kt | 14 ++++-- .../screens/detail_screen/DetailPage.kt | 3 ++ .../components/DetailsBottomSheet.kt | 5 ++ .../components/PasswordSettingsSheet.kt | 5 ++ .../screens/preview_screen/PreviewPage.kt | 3 ++ .../components/PreviewBottomSheet.kt | 5 ++ .../screens/security_screen/SecurityPage.kt | 2 + .../screens/settings_screen/SettingsPage.kt | 5 ++ .../components/AnalysisBottomSheet.kt | 5 ++ .../com/bhardwaj/passkey/utils/Constants.kt | 2 - 16 files changed, 103 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt index 4afb3a4..4e22df6 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt @@ -33,4 +33,8 @@ interface DetailsDao { @Query("UPDATE $DETAILS_TABLE SET sequence=:sequence WHERE detailsId=:detailsId") suspend fun updateDetailSequence(detailsId: Long, sequence: Long) + + @Query("DELETE FROM $DETAILS_TABLE") + suspend fun deleteAllDetails() + } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt index d13f0f7..341364c 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt @@ -27,4 +27,7 @@ interface PreviewDao { @Query("UPDATE $PREVIEW_TABLE SET sequence=:sequence WHERE previewId=:previewId") suspend fun updatePreviewSequence(previewId: Long, sequence: Long) + + @Query("DELETE FROM $PREVIEW_TABLE") + suspend fun deleteAllPreviews() } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt b/app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt index 5b77af6..8be27f4 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt @@ -34,4 +34,18 @@ interface PasskeyRepository { suspend fun deletePreview(previews: Preview) suspend fun updatePreviewSequence(previewId: Long, sequence: Long) + + // Cross-cutting + + /** + * Runs [block] inside a single database transaction. + * + * Import previously wrote row by row with no transaction, so a malformed row part-way + * through a file left the vault half-populated with no way back. Reordering had the same + * problem: N separate sequence writes that could be interrupted between any two. + */ + suspend fun runInTransaction(block: suspend () -> R): R + + /** Removes every entry. Only meaningful inside [runInTransaction]. */ + suspend fun deleteAll() } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt index 0cd61de..0fbb8b0 100644 --- a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt +++ b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt @@ -44,7 +44,7 @@ object AppModule { @Provides @Singleton fun provideTodoRepository(db: PassKeyDatabase): PasskeyRepository { - return PasskeyRepositoryImpl(db.previewDao, db.detailsDao) + return PasskeyRepositoryImpl(db, db.previewDao, db.detailsDao) } @Provides diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt index 343bd78..42ebe27 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt @@ -1,5 +1,7 @@ package com.bhardwaj.passkey.domain.repository +import androidx.room.withTransaction +import com.bhardwaj.passkey.data.local.PassKeyDatabase import com.bhardwaj.passkey.data.local.dao.DetailsDao import com.bhardwaj.passkey.data.local.dao.PreviewDao import com.bhardwaj.passkey.data.local.entity.Details @@ -8,9 +10,18 @@ import com.bhardwaj.passkey.data.repository.PasskeyRepository import kotlinx.coroutines.flow.Flow class PasskeyRepositoryImpl( + private val database: PassKeyDatabase, private val previewDao: PreviewDao, private val detailsDao: DetailsDao ) : PasskeyRepository { + + override suspend fun runInTransaction(block: suspend () -> R): R = + database.withTransaction { block() } + + override suspend fun deleteAll() { + detailsDao.deleteAllDetails() + previewDao.deleteAllPreviews() + } override fun getDetails(): Flow> { return detailsDao.getDetails() } diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt index 8ab2f38..a4939ab 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt @@ -12,8 +12,6 @@ import com.bhardwaj.passkey.data.local.entity.Details import com.bhardwaj.passkey.data.repository.PasskeyRepository import com.bhardwaj.passkey.domain.events.DetailEvents import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING -import com.bhardwaj.passkey.utils.Constants.Companion.DETAIL_RESPONSE -import com.bhardwaj.passkey.utils.Constants.Companion.DETAIL_TITLE import com.bhardwaj.passkey.utils.PasswordGenerator import com.bhardwaj.passkey.utils.UiEvents import com.bhardwaj.passkey.utils.UiText @@ -37,8 +35,16 @@ class DetailViewModel @Inject constructor( private val _uiEvents = Channel() val uiEvents = _uiEvents.receiveAsFlow() - val detailTitle = savedStateHandle.getStateFlow(DETAIL_TITLE, "") - val detailResponse = savedStateHandle.getStateFlow(DETAIL_RESPONSE, "") + // Deliberately NOT in SavedStateHandle. That is serialized into the saved-instance-state + // bundle, which the system writes to disk under /data/system_ce// for task + // persistence - so the in-progress secret, including a freshly generated password, would be + // stored in cleartext outside the encrypted database. Losing a half-typed entry to process + // death is the correct trade here. + private val _detailTitle = MutableStateFlow("") + val detailTitle = _detailTitle.asStateFlow() + + private val _detailResponse = MutableStateFlow("") + val detailResponse = _detailResponse.asStateFlow() /** true = editing an existing row, false = adding. Resolved to text by the UI. */ val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) val previewId = savedStateHandle.get("previewId") ?: -1 @@ -95,8 +101,8 @@ class DetailViewModel @Inject constructor( } DetailEvents.OnDismissBottomSheet -> { - savedStateHandle[DETAIL_TITLE] = "" - savedStateHandle[DETAIL_RESPONSE] = "" + _detailTitle.value = "" + _detailResponse.value = "" isSheetOpen = false } @@ -106,27 +112,27 @@ class DetailViewModel @Inject constructor( repository.getDetailById(event.details.detailsId!!)?.let { detail -> isSheetOpen = true savedStateHandle[BOTTOM_SHEET_HEADING] = true - savedStateHandle[DETAIL_TITLE] = event.details.question - savedStateHandle[DETAIL_RESPONSE] = event.details.answer + _detailTitle.value = event.details.question + _detailResponse.value = event.details.answer this@DetailViewModel.detail = detail } } } is DetailEvents.OnTitleChange -> { - savedStateHandle[DETAIL_TITLE] = event.newTitle + _detailTitle.value = event.newTitle } is DetailEvents.OnDescriptionChange -> { - savedStateHandle[DETAIL_RESPONSE] = event.newDescription + _detailResponse.value = event.newDescription } DetailEvents.OnSaveClick -> { viewModelScope.launch { if (detailTitle.value.isBlank() or detailResponse.value.isBlank()) { isSheetOpen = false - savedStateHandle[DETAIL_TITLE] = "" - savedStateHandle[DETAIL_RESPONSE] = "" + _detailTitle.value = "" + _detailResponse.value = "" sendUiEvents( UiEvents.ShowSnackBar( message = UiText.StringResource(R.string.enter_valid_title_n_response) @@ -162,8 +168,8 @@ class DetailViewModel @Inject constructor( } ?: repository.upsertDetails(newDetail) detail = null - savedStateHandle[DETAIL_TITLE] = "" - savedStateHandle[DETAIL_RESPONSE] = "" + _detailTitle.value = "" + _detailResponse.value = "" isSheetOpen = false } } @@ -215,11 +221,13 @@ class DetailViewModel @Inject constructor( detail.copy(sequence = index.toLong()) } - updatedList.forEach { detail -> - repository.updateDetailSequence( - detailId = detail.detailsId!!, - sequence = detail.sequence - ) + repository.runInTransaction { + updatedList.forEach { detail -> + repository.updateDetailSequence( + detailId = detail.detailsId!!, + sequence = detail.sequence + ) + } } } } @@ -232,7 +240,7 @@ class DetailViewModel @Inject constructor( includeNumbers = includeNumbers, includeSpecial = includeSpecial ) - savedStateHandle[DETAIL_RESPONSE] = newPassword + _detailResponse.value = newPassword } DetailEvents.OnPasswordSettingsClick -> { diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt index d695c53..90a35bf 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt @@ -217,11 +217,15 @@ class PreviewViewModel @Inject constructor( preview.copy(sequence = index.toLong()) } - updatedList.forEach { preview -> - repository.updatePreviewSequence( - previewId = preview.previewId!!, - sequence = preview.sequence - ) + // One transaction, not N separate writes: a reorder interrupted midway + // used to leave the list in a partially renumbered state. + repository.runInTransaction { + updatedList.forEach { preview -> + repository.updatePreviewSequence( + previewId = preview.previewId!!, + sequence = preview.sequence + ) + } } } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 8e90792..5b2ea51 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -38,6 +38,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -295,6 +297,7 @@ fun DetailScreen( } if (viewModel.isAlertOpen) { AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { viewModel.onEvent(DetailEvents.OnDismissAlertDialog) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt index 6f49db2..a17b5a3 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt @@ -22,6 +22,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -57,6 +59,9 @@ fun DetailsBottomSheet( ) { val sheetState = rememberModalBottomSheetState() ModalBottomSheet( + properties = ModalBottomSheetProperties( + securePolicy = SecureFlagPolicy.SecureOn + ), sheetState = sheetState, onDismissRequest = { onDismiss() }, dragHandle = {}, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt index 339eeee..9fa3c4a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch @@ -44,6 +46,9 @@ fun PasswordSettingsSheet( val sheetState = rememberModalBottomSheetState() ModalBottomSheet( + properties = ModalBottomSheetProperties( + securePolicy = SecureFlagPolicy.SecureOn + ), sheetState = sheetState, onDismissRequest = { onDismissPasswordSettings() }, dragHandle = { }, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index 11304b4..3f0ec41 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -49,6 +49,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -318,6 +320,7 @@ fun PreviewScreen( } if (viewModel.isAlertOpen) { AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { viewModel.onEvent(PreviewEvents.OnDismissAlertDialog) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt index 6bebbe1..afedf89 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -41,6 +43,9 @@ fun PreviewBottomSheet( ) { val sheetState = rememberModalBottomSheetState() ModalBottomSheet( + properties = ModalBottomSheetProperties( + securePolicy = SecureFlagPolicy.SecureOn + ), sheetState = sheetState, onDismissRequest = { onDismiss() }, dragHandle = {}, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt index 314207e..fa49a77 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -126,6 +127,7 @@ fun SecurityScreen( if (viewModel.needToOpenAlertDialog) { Dialog( onDismissRequest = {}, properties = DialogProperties( + securePolicy = SecureFlagPolicy.SecureOn, dismissOnBackPress = false, dismissOnClickOutside = false ) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index 2335c78..72ff869 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -20,6 +20,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -122,6 +124,9 @@ fun SettingsScreen( if (viewModel.isSheetOpen) { val sheetState = rememberModalBottomSheetState() ModalBottomSheet( + properties = ModalBottomSheetProperties( + securePolicy = SecureFlagPolicy.SecureOn + ), sheetState = sheetState, onDismissRequest = { viewModel.onEvent(SettingsEvents.OnDismissBottomSheet) }, dragHandle = {}, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt index d736d32..ead603a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt @@ -29,6 +29,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -64,6 +66,9 @@ fun AnalysisBottomSheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( + properties = ModalBottomSheetProperties( + securePolicy = SecureFlagPolicy.SecureOn + ), sheetState = sheetState, onDismissRequest = { onDismiss() }, containerColor = MaterialTheme.colorScheme.background diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt b/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt index 5586d59..09dd5ec 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt @@ -15,8 +15,6 @@ class Constants { // SaveState Constants. const val PREVIEW_HEADING = "preview_heading" const val PREVIEW_CATEGORY_NAME = "preview_category_name" - const val DETAIL_TITLE = "detail_title" - const val DETAIL_RESPONSE = "detail_response" const val BOTTOM_SHEET_HEADING = "bottom_sheet_heading" // DataStore Prefs Constants. From 89f43e75084804171ae64d54916555db7b61dc79 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 08:45:55 +0530 Subject: [PATCH 05/25] Replace the plaintext CSV export with an encrypted backup format Export wrote the entire vault as readable CSV into the public Downloads folder. The .passkey extension and application/passkey MIME type were cosmetic: the bytes were credentials in cleartext, visible to any app with media access and swept up by cloud backup tools. This is the single worst data-handling defect in the app. New .pkbak format: "PKBACKUP" magic, format version, KDF id and parameters, salt, nonce and length, followed by AES-256-GCM ciphertext over GZIPped JSON. The whole 51-byte header is passed to GCM as additional authenticated data, so the KDF identifier and its parameters are covered by the auth tag - otherwise an attacker could rewrite kdfId to name a weaker KDF and the file would still verify. There is a test for exactly that. Keys come from PBKDF2-HMAC-SHA512 at 600k iterations. Argon2id is stronger against GPU attack and is what the kdfId field exists to allow; it needs a native dependency and is planned alongside the 5.7.0 key work, at which point new files get kdfId 2 and this reader keeps handling kdfId 1. Files are written through SAF CreateDocument so the user picks the destination, using "wt" so an existing file is truncated rather than partially overwritten. The backup password is collected only after a destination is chosen, and entered twice with an explicit warning that it cannot be recovered. Import is hardened throughout: - format detection is by magic bytes, not filename. The old check matched a display name ending in "passkey", which would have rejected every new backup and trusted a user-supplied name to decide how to parse the contents. - the whole restore runs in one transaction, so a bad row part-way through a file can no longer leave a half-populated vault. - unknown categories map to OTHERS instead of Categories.valueOf throwing into a broad catch that silently abandoned the rest of the file. - field lengths, row counts and file size are bounded against a hostile file. - a wrong password and a corrupt file are deliberately indistinguishable, since GCM cannot tell them apart and pretending otherwise leaks whether a guess was close. The legacy plaintext CSV stays readable so existing backups still restore, now via opencsv - which was already a declared but entirely unused dependency - so quoted values containing commas parse correctly rather than being split blindly. Rows with the wrong column count are dropped and counted rather than aborting the import. The lossy "____" comma sentinel is un-escaped on read; nothing can recover a value that genuinely contained it, but that is what the file means. Only the reader survives; the CSV writer is gone. SettingsViewModel drops from 340 to 212 lines with the file I/O and MediaStore code removed. Manifest, now that SAF has removed the need for storage access: - READ_/WRITE_EXTERNAL_STORAGE deleted. They were capped at maxSdkVersion 28 while minSdk is 28, so they were live on exactly the oldest supported devices. - allowBackup="false". Platform backup would carry the SQLCipher database off the device, and with today's build-time key that copy is decryptable by anyone with the APK. backup_rules.xml and data_extraction_rules.xml were untouched IDE templates and are now correct regardless, so flipping the flag back cannot silently start uploading the vault. - INTERNET is declared explicitly. The app makes no network calls itself, but Firebase and Play in-app-update require it, and having it merged in invisibly from a library manifest is how the README came to claim otherwise. The merged manifest of a built APK also shows AD_ID and the AdServices permissions arriving from firebase-analytics, which the privacy rewrite has to disclose. - MainActivity is singleTask so a second instance cannot come up holding a stale unlocked session once app-lock lands. 24 new unit tests cover the crypto round-trip, tamper detection on both ciphertext and header, and the legacy CSV reader including the shapes the old exporter got wrong. Round-tripping a value containing commas, newlines, the ____ sentinel and emoji is now covered, none of which survived the old format. Co-Authored-By: Claude Opus 5 (1M context) --- .idea/deploymentTargetSelector.xml | 1 + .idea/kotlinc.xml | 4 +- .idea/ktlint-plugin.xml | 7 + app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 20 +- .../passkey/data/backup/BackupCrypto.kt | 128 +++++++++ .../passkey/data/backup/BackupFormat.kt | 126 +++++++++ .../passkey/data/backup/BackupPayload.kt | 42 +++ .../passkey/data/backup/BackupRepository.kt | 242 ++++++++++++++++++ .../passkey/data/backup/LegacyCsvBackup.kt | 68 +++++ .../passkey/domain/events/SettingsEvents.kt | 30 ++- .../domain/viewModels/SettingsViewModel.kt | 192 +++----------- .../screens/settings_screen/SettingsPage.kt | 108 +++++--- .../components/BackupPasswordDialog.kt | 123 +++++++++ .../com/bhardwaj/passkey/utils/Constants.kt | 4 - app/src/main/res/values-ar/strings.xml | 16 +- app/src/main/res/values-bn/strings.xml | 16 +- app/src/main/res/values-de/strings.xml | 16 +- app/src/main/res/values-es/strings.xml | 16 +- app/src/main/res/values-fr/strings.xml | 16 +- app/src/main/res/values-gu/strings.xml | 16 +- app/src/main/res/values-hi/strings.xml | 16 +- app/src/main/res/values-it/strings.xml | 16 +- app/src/main/res/values-ja/strings.xml | 16 +- app/src/main/res/values-ko/strings.xml | 16 +- app/src/main/res/values-mr/strings.xml | 16 +- app/src/main/res/values-pt/strings.xml | 16 +- app/src/main/res/values-ru/strings.xml | 16 +- app/src/main/res/values-ta/strings.xml | 16 +- app/src/main/res/values-te/strings.xml | 16 +- app/src/main/res/values-zh/strings.xml | 16 +- app/src/main/res/values/strings.xml | 18 +- app/src/main/res/xml/backup_rules.xml | 23 +- .../main/res/xml/data_extraction_rules.xml | 26 +- .../com/bhardwaj/passkey/BackupCryptoTest.kt | 98 +++++++ .../bhardwaj/passkey/LegacyCsvBackupTest.kt | 78 ++++++ gradle/libs.versions.toml | 3 + 37 files changed, 1355 insertions(+), 248 deletions(-) create mode 100644 .idea/ktlint-plugin.xml create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/backup/BackupCrypto.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/backup/BackupFormat.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/backup/LegacyCsvBackup.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/BackupPasswordDialog.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/BackupCryptoTest.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/LegacyCsvBackupTest.kt diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..ca16a99 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,7 @@ diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index aabf371..1dd6491 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -4,8 +4,8 @@

): PasswordAnalysisResult { - val passwordEntries = details.filter { - val q = it.question.lowercase() - q.contains("password") || q.contains("pin") || q.contains("code") || q.contains("secret") - } - - val weakList = mutableListOf
() - val reuseMap = mutableMapOf>() - - passwordEntries.forEach { detail -> - val pass = detail.answer - - if (isWeak(pass)) { - weakList.add(detail) - } + /** + * Analyses the vault. + * + * [secretKeywords] must be supplied by the caller from `R.array.secret_field_keywords`, + * unioned across the default and current locale. Previously this object hardcoded the + * English words "password", "pin", "code" and "secret", so across the app's 16 non-English + * locales nothing matched: totalPasswords came back 0, the score came back 100, and users + * were told their vault was perfect. That is worse than having no feature at all. + */ + fun analyze(details: List
, secretKeywords: Set): PasswordAnalysisResult { + val normalizedKeywords = secretKeywords.map { it.normalizeForMatch() }.filter { it.isNotBlank() } - if (reuseMap.containsKey(pass)) { - reuseMap[pass]?.add(detail) - } else { - reuseMap[pass] = mutableListOf(detail) + val passwordEntries = details.filter { detail -> + detail.isSecret || normalizedKeywords.any { keyword -> + detail.question.normalizeForMatch().contains(keyword) } } - val actualReused = reuseMap.filter { it.value.size > 1 } + val weakList = passwordEntries.filter { isWeak(it.answer) } - var score = 100 - val weakPenalty = (weakList.size * 10).coerceAtMost(50) - val reusedPenalty = (actualReused.size * 15).coerceAtMost(50) + // Keyed by a digest rather than the plaintext: this map is held in ViewModel state and + // rendered by the analysis sheet, and there is no reason for cleartext passwords to sit + // in a composition-scoped object. + val reused = passwordEntries + .groupBy { it.answer } + .filterValues { it.size > 1 } + .mapKeys { (password, _) -> password.digestKey() } - score -= (weakPenalty + reusedPenalty) - if (passwordEntries.isEmpty()) score = 100 + var score = 100 + if (passwordEntries.isNotEmpty()) { + val weakPenalty = (weakList.size * 10).coerceAtMost(50) + // Counts affected entries, not distinct duplicated values: three copies of one + // password used to score the same as two, which understated the problem. + val reusedEntryCount = reused.values.sumOf { it.size } + val reusedPenalty = (reusedEntryCount * 15).coerceAtMost(50) + score -= (weakPenalty + reusedPenalty) + } return PasswordAnalysisResult( totalPasswords = passwordEntries.size, weakPasswords = weakList, - reusedPasswords = actualReused, + reusedPasswords = reused, strengthScore = score.coerceAtLeast(0) ) } @@ -55,6 +63,20 @@ object PasswordAnalyzer { if (password.length < 8) return true if (password.all { it.isDigit() }) return true if (password.all { it.isLetter() }) return true + if (password.toSet().size == 1) return true return false } -} \ No newline at end of file + + /** Case-folded and accent-stripped, so "Contraseña" and "contrasena" both match. */ + private fun String.normalizeForMatch(): String = + Normalizer.normalize(this, Normalizer.Form.NFKD) + .replace(Regex("\\p{Mn}+"), "") + .lowercase(Locale.ROOT) + .trim() + + private fun String.digestKey(): String = + java.security.MessageDigest.getInstance("SHA-256") + .digest(toByteArray(Charsets.UTF_8)) + .take(8) + .joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a16f75c..5fa2e9c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -130,4 +130,13 @@ بعد 5 دقائق أبدًا ستبقى الخزنة مفتوحة حتى تغلق التطبيق. يمكن لأي شخص يحمل هاتفك المفتوح قراءة كلمات مرورك. + + كلمة المرور + كلمة السر + رمز + الرقم السري + سر + مفتاح + تسجيل الدخول + \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index a52464b..f8a9cfc 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -126,4 +126,13 @@ ৫ মিনিট পরে কখনও নয় অ্যাপ বন্ধ না করা পর্যন্ত ভল্ট আনলক থাকবে। আপনার আনলক করা ফোন যার হাতে থাকবে সে পাসওয়ার্ড দেখতে পারবে। + + পাসওয়ার্ড + পিন + কোড + গোপন + গুপ্ত + চাবি + লগইন + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b7345e3..117f6e4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -126,4 +126,14 @@ Nach 5 Minuten Nie Der Tresor bleibt offen, bis du die App schließt. Wer dein entsperrtes Telefon hat, kann deine Passwörter lesen. + + passwort + kennwort + pin + code + geheim + schlüssel + zugangsdaten + anmeldung + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ac4ab10..2547279 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -127,4 +127,13 @@ Tras 5 minutos Nunca La caja fuerte seguirá abierta hasta que cierres la app. Quien tenga tu teléfono desbloqueado podrá leer tus contraseñas. + + contraseña + clave + pin + código + secreto + llave + acceso + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6e2b961..c819cbf 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -127,4 +127,13 @@ Après 5 minutes Jamais Le coffre restera déverrouillé jusqu\'à la fermeture de l\'application. Toute personne ayant votre téléphone déverrouillé pourra lire vos mots de passe. + + mot de passe + code + pin + secret + clé + identifiant + connexion + diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index 21a5e36..b7e07d6 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -126,4 +126,12 @@ 5 મિનિટ પછી ક્યારેય નહીં ઍપ બંધ ન કરો ત્યાં સુધી વૉલ્ટ ખુલ્લી રહેશે. તમારો અનલૉક કરેલો ફોન જેની પાસે હશે તે તમારા પાસવર્ડ વાંચી શકશે. + + પાસવર્ડ + પિન + કોડ + ગુપ્ત + ચાવી + લૉગિન + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 2765c37..1c90f86 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -126,4 +126,13 @@ 5 मिनट बाद कभी नहीं ऐप बंद करने तक वॉल्ट खुली रहेगी। जिसके पास आपका अनलॉक फ़ोन होगा वह आपके पासवर्ड पढ़ सकता है। + + पासवर्ड + पिन + कोड + गुप्त + गोपनीय + कुंजी + लॉगिन + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4d322ef..b92df5a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -127,4 +127,13 @@ Dopo 5 minuti Mai La cassaforte resterà aperta finché non chiudi l\'app. Chi ha il tuo telefono sbloccato potrà leggere le tue password. + + password + parola d\'ordine + pin + codice + segreto + chiave + accesso + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index a61cb71..9ee1dd5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -125,4 +125,12 @@ 5 分後 しない アプリを閉じるまで保管庫は開いたままになります。ロック解除された端末を手にした人は誰でもパスワードを見られます。 + + パスワード + 暗証番号 + コード + 秘密 + 鍵 + ログイン + \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6eb4952..9931faf 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -125,4 +125,13 @@ 5분 후 안 함 앱을 닫을 때까지 보관함이 열린 채로 유지됩니다. 잠금 해제된 기기를 가진 사람은 누구나 비밀번호를 볼 수 있습니다. + + 비밀번호 + 암호 + 핀 + 코드 + 비밀 + 키 + 로그인 + \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index d2bc662..5366c0d 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -126,4 +126,13 @@ 5 मिनिटांनी कधीही नाही ॲप बंद करेपर्यंत व्हॉल्ट उघडी राहील. तुमचा अनलॉक केलेला फोन ज्याच्याकडे असेल तो तुमचे पासवर्ड वाचू शकतो. + + पासवर्ड + पिन + कोड + गुप्त + गोपनीय + किल्ली + लॉगिन + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fd785f0..1fcca50 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -127,4 +127,13 @@ Após 5 minutos Nunca O cofre ficará aberto até você fechar o app. Quem tiver seu celular desbloqueado poderá ler suas senhas. + + senha + palavra-passe + pin + código + segredo + chave + acesso + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a71238a..2672789 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -128,4 +128,12 @@ Через 5 минут Никогда Хранилище останется разблокированным, пока вы не закроете приложение. Любой, у кого окажется разблокированный телефон, увидит ваши пароли. + + пароль + пин + код + секрет + ключ + вход + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 4c6c838..c9d0355 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -126,4 +126,12 @@ 5 நிமிடங்களுக்குப் பிறகு ஒருபோதும் இல்லை ஆப்ஸை மூடும் வரை பெட்டகம் திறந்தே இருக்கும். உங்கள் திறந்த ஃபோன் யாரிடம் இருந்தாலும் கடவுச்சொற்களைப் படிக்க முடியும். + + கடவுச்சொல் + பின் + குறியீடு + ரகசியம் + சாவி + உள்நுழைவு + diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index d925d26..c1ca3fa 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -126,5 +126,13 @@ 5 నిమిషాల తర్వాత ఎప్పుడూ కాదు యాప్ మూసే వరకు వాల్ట్ తెరిచే ఉంటుంది. మీ అన్‌లాక్ చేసిన ఫోన్ ఎవరి దగ్గర ఉన్నా మీ పాస్‌వర్డ్‌లు చదవగలరు. + + పాస్‌వర్డ్ + పిన్ + కోడ్ + రహస్యం + కీ + లాగిన్ + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0d8090a..c8e88d2 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -125,4 +125,13 @@ 5 分钟后 永不 在你关闭应用之前密码库将保持解锁。任何拿到你已解锁手机的人都能读取你的密码。 + + 密码 + 口令 + 密钥 + 验证码 + 代码 + 秘密 + 登录 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a53f86..c283603 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,4 +162,21 @@ After 5 minutes Never The vault will stay unlocked until you close the app. Anyone with your unlocked phone can read your passwords. + + + + password + pass + pin + passcode + secret + code + key + login + credential + \ No newline at end of file diff --git a/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt b/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt new file mode 100644 index 0000000..bdc68a8 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt @@ -0,0 +1,96 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.utils.PasswordAnalyzer +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PasswordAnalyzerTest { + + private val english = setOf("password", "pin", "code", "secret") + private val hindi = setOf("पासवर्ड", "पिन") + + private fun detail( + id: Long, question: String, answer: String, isSecret: Boolean = false + ) = Details( + detailsId = id, previewId = 1, question = question, answer = answer, isSecret = isSecret + ) + + @Test + fun `empty vault scores 100`() { + val result = PasswordAnalyzer.analyze(emptyList(), english) + assertThat(result.strengthScore).isEqualTo(100) + assertThat(result.totalPasswords).isEqualTo(0) + } + + @Test + fun `entries labelled in a non-English locale are classified`() { + // The whole point: with hardcoded English keywords this returned 0 passwords and a + // perfect score for every user of the 16 translated locales. + val details = listOf(detail(1, "पासवर्ड", "abc")) + val result = PasswordAnalyzer.analyze(details, english + hindi) + assertThat(result.totalPasswords).isEqualTo(1) + assertThat(result.weakPasswords).hasSize(1) + } + + @Test + fun `the isSecret flag classifies regardless of label language`() { + val details = listOf(detail(1, "مفتاح", "abc", isSecret = true)) + val result = PasswordAnalyzer.analyze(details, english) + assertThat(result.totalPasswords).isEqualTo(1) + } + + @Test + fun `matching ignores case and accents`() { + val details = listOf(detail(1, "Contraseña principal", "abc")) + val result = PasswordAnalyzer.analyze(details, setOf("contrasena")) + assertThat(result.totalPasswords).isEqualTo(1) + } + + @Test + fun `non-secret fields are ignored`() { + val details = listOf(detail(1, "Username", "alice"), detail(2, "Notes", "hello")) + assertThat(PasswordAnalyzer.analyze(details, english).totalPasswords).isEqualTo(0) + } + + @Test + fun `weak passwords are detected`() { + val details = listOf( + detail(1, "Password", "short"), // < 8 + detail(2, "PIN", "12345678"), // all digits + detail(3, "Password 3", "abcdefgh"), // all letters + detail(4, "Password 4", "aaaaaaaa"), // single repeated character + detail(5, "Password 5", "Str0ng!Pass") // fine + ) + val result = PasswordAnalyzer.analyze(details, english) + assertThat(result.weakPasswords.map { it.detailsId }).containsExactly(1L, 2L, 3L, 4L) + } + + @Test + fun `weak penalty is capped at 50`() { + val details = (1..10L).map { detail(it, "Password $it", "abc") } + // Ten weak entries would be -100 uncapped; the floor must still be 50 here. + val result = PasswordAnalyzer.analyze(details, english) + assertThat(result.strengthScore).isAtLeast(0) + assertThat(result.weakPasswords).hasSize(10) + } + + @Test + fun `reuse groups only include duplicates and are not keyed by plaintext`() { + val details = listOf( + detail(1, "Password A", "Repeated1!"), + detail(2, "Password B", "Repeated1!"), + detail(3, "Password C", "Unique9!x") + ) + val result = PasswordAnalyzer.analyze(details, english) + assertThat(result.reusedPasswords).hasSize(1) + assertThat(result.reusedPasswords.values.single()).hasSize(2) + assertThat(result.reusedPasswords.keys.single()).doesNotContain("Repeated1!") + } + + @Test + fun `score never drops below zero`() { + val details = (1..40L).map { detail(it, "Password $it", "aaa") } + assertThat(PasswordAnalyzer.analyze(details, english).strengthScore).isAtLeast(0) + } +} From f5eb611849516a543f5c0e27d0d08a59dc442b1d Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 09:28:59 +0530 Subject: [PATCH 09/25] Correct the privacy claims, and translate About/Privacy/Terms into all locales The in-app About, Privacy and Terms text, README.md and Privacy Policy.md all stated that the app requires no internet permission and shares nothing with third parties. That was false in the shipped APK. Dumping the merged manifest of a build shows INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK, AD_ID and the Play AdServices permissions all arriving from firebase-analytics and the Play update library. The repo's Privacy Policy.md simultaneously contradicted the in-app copy by describing IP address and device name collection. All three in-app texts are rewritten to be accurate and, deliberately, far shorter. The previous About text was several hundred words of marketing that also claimed LiveData and MVVM (the app uses Flow), dark mode (half-implemented until this release) and Autofill (still not implemented). Accurate, concise copy is both better for users and tractable to translate. The rewritten text states plainly: vault entries are never collected, transmitted or stored by us and cannot be recovered by us; Firebase Crashlytics, Analytics and Performance Monitoring plus Play in-app updates send crash diagnostics, usage events, performance traces and device/installation identifiers including an advertising ID to Google, and never have access to vault contents; backups are the user's responsibility once written; and the vault is excluded from Android cloud backup and device transfer. Privacy Policy.md is rewritten around the actual architecture with a per-component table of what each one sends and a permission-by-permission justification. README.md drops "Fully Offline : No internet permission required", gains a security model section, and states the current database-key limitation openly rather than letting the feature list imply protection the build does not yet provide. With the text corrected it is now safe to translate, which completes the decision to localize everything: about_message, privacy_message and terms_n_condition_message are translated into all 16 locales. app_name is the only remaining translatable="false" string, being the brand name. These three are long-form legal and descriptive text produced by machine translation. They should get a native-speaker review pass before release; the technical claims in them are accurate, but phrasing and legal register have not been checked by a human for each language. Version bumped to 5.6.0 (versionCode 45). Co-Authored-By: Claude Opus 5 (1M context) --- Privacy Policy.md | 152 +++++++++++-------------- README.md | 53 ++++++--- app/build.gradle.kts | 4 +- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-bn/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-gu/strings.xml | 3 + app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-mr/strings.xml | 3 + app/src/main/res/values-pt/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-ta/strings.xml | 3 + app/src/main/res/values-te/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 49 ++++++-- 20 files changed, 194 insertions(+), 112 deletions(-) diff --git a/Privacy Policy.md b/Privacy Policy.md index d2b196d..3466546 100644 --- a/Privacy Policy.md +++ b/Privacy Policy.md @@ -1,84 +1,68 @@ -# Privacy Policy - -Aditya Bhardwaj built the PassKey app as an Open Source app. This SERVICE is provided by Aditya -Bhardwaj at no cost and is intended for use as is. -This page is used to inform visitors regarding my policies with the collection, use, and disclosure -of Personal Information if anyone decided to use my Service. -If you choose to use my Service, then you agree to the collection and use of information in relation -to this policy. The Personal Information that I collect is used for providing and improving the -Service. I will not use or share your information with anyone except as described in this Privacy -Policy. -The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which -are accessible at PassKey unless otherwise defined in this Privacy Policy. - -# Information Collection and Use - -For a better experience, while using our Service, I may require you to provide us with certain -personally identifiable information, including but not limited to Aditya Bhardwaj. The information -that I request will be retained on your device and is not collected by me in any way. -The app does use third-party services that may collect information used to identify you. -Link to the privacy policy of third-party service providers used by the app -Google Play Services - -# Log Data - -I want to inform you that whenever you use my Service, in a case of an error in the app I collect -data and information (through third-party products) on your phone called Log Data. This Log Data may -include information such as your device Internet Protocol (“IP”) address, device name, operating -system version, the configuration of the app when utilizing my Service, the time and date of your -use of the Service, and other statistics. - -# Cookies - -Cookies are files with a small amount of data that are commonly used as anonymous unique -identifiers. These are sent to your browser from the websites that you visit and are stored on your -device's internal memory. -This Service does not use these “cookies” explicitly. However, the app may use third-party code and -libraries that use “cookies” to collect information and improve their services. You have the option -to either accept or refuse these cookies and know when a cookie is being sent to your device. If you -choose to refuse our cookies, you may not be able to use some portions of this Service. - -# Service Providers - -I may employ third-party companies and individuals due to the following reasons: -To facilitate our Service; -To provide the Service on our behalf; -To perform Service-related services; or -To assist us in analyzing how our Service is used. -I want to inform users of this Service that these third parties have access to their Personal -Information. The reason is to perform the tasks assigned to them on our behalf. However, they are -obligated not to disclose or use the information for any other purpose. - -# Security - -I value your trust in providing us your Personal Information, thus we are striving to use -commercially acceptable means of protecting it. But remember that no method of transmission over the -internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its -absolute security. - -# Links to Other Sites - -This Service may contain links to other sites. If you click on a third-party link, you will be -directed to that site. Note that these external sites are not operated by me. Therefore, I strongly -advise you to review the Privacy Policy of these websites. I have no control over and assume no -responsibility for the content, privacy policies, or practices of any third-party sites or services. - -# Children’s Privacy - -These Services do not address anyone under the age of 13. I do not knowingly collect personally -identifiable information from children under 13 years of age. In the case I discover that a child -under 13 has provided me with personal information, I immediately delete this from our servers. If -you are a parent or guardian and you are aware that your child has provided us with personal -information, please contact me so that I will be able to do the necessary actions. - -# Changes to This Privacy Policy - -I may update our Privacy Policy from time to time. Thus, you are advised to review this page -periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on -this page. -This policy is effective as of 2022-08-30 - -# Contact Us - -If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at -yrkkh.cclub@gmail.com. +# Privacy Policy — PassKey: Password Manager + +_Last updated: 2026-09-16_ + +## Summary + +Your vault stays on your device. PassKey has no account, no sync and no server of its own. The +app does, however, bundle Google Firebase and Google Play libraries that send diagnostic and +usage data to Google. This document says exactly what does and does not leave your phone. + +## What we never collect + +PassKey does not collect, transmit or store the passwords, usernames, notes or any other entries +you save. They exist only in an encrypted database on your device. + +We cannot read your entries and we cannot recover them for you. There is no server-side copy. + +## What the app does send + +PassKey includes the following third-party components, all provided by Google: + +| Component | What it sends | +|---|---| +| Firebase Crashlytics | Crash stack traces, device model, OS version, app version, a Crashlytics installation identifier | +| Firebase Analytics | App usage events, session and screen information, device and installation identifiers, and an advertising ID (`AD_ID`) | +| Firebase Performance Monitoring | Startup, screen rendering and trace timings | +| Google Play in-app updates | Communicates with the Play Store to check for and install updates | + +Because of these components the app requests the `INTERNET` permission, along with +`ACCESS_NETWORK_STATE`, `WAKE_LOCK`, `AD_ID` and the Play AdServices attribution permissions. +None of these components has access to your vault contents. + +Data handled by these services is processed by Google under +[Google's privacy policy](https://policies.google.com/privacy). + +## Backups + +When you export a backup, the file is encrypted with AES-256-GCM using a key derived from a +password you choose, and written to the location you pick through the system file picker. + +That file is then yours to manage. Anyone who obtains **both** the file and its password can read +it. We keep no copy and cannot recover the password for you. + +## Device backup + +PassKey sets `android:allowBackup="false"` and excludes its data from both Android cloud backup +and device-to-device transfer, so your vault is not uploaded to your Google account. + +## Permissions + +| Permission | Why | +|---|---| +| `USE_BIOMETRIC` | Unlocking the vault | +| `INTERNET`, `ACCESS_NETWORK_STATE`, `WAKE_LOCK` | Required by the Firebase and Google Play libraries above | +| `AD_ID`, AdServices permissions | Merged in by Firebase Analytics | + +## Children + +PassKey is not directed at children and does not knowingly collect information from them. + +## Changes + +This policy may be updated. Material changes will be noted in the app's release notes and in this +file's history. + +## Contact + +yrkkh.cclub@gmail.com diff --git a/README.md b/README.md index add1e3d..281b932 100644 --- a/README.md +++ b/README.md @@ -30,22 +30,43 @@ design -> https://www.figma.com/community/file/1116675775484733517 ## 🤩 Features: -- Kotlin & Jetpack Compose. -- Clean Architecture. -- Kotlin Flows. -- Dark Mode Support. -- Biometric Authentication. -- Dagger-Hilt (Dependency Injection). -- DataStore Preferences. -- Encrypted Database. -- Screenshot Blocked. -- Open Source. -- Drag & Drop to reorder list. -- Swipe to Delete Feature. -- Separation of content. -- Import / Export data to and from storage. -- SQL Cipher (For Room DB Encryption and Decryption). -- Fully Offline : No internet permission required. +- Kotlin & Jetpack Compose, Material 3. +- Clean architecture with Kotlin Flows and Dagger-Hilt. +- Encrypted database (Room + SQLCipher). +- Biometric / device-credential unlock, with automatic re-lock on background and idle. +- Screenshot blocking, including dialogs and bottom sheets. +- Clipboard entries marked sensitive and cleared automatically. +- Password generator backed by SecureRandom. +- Vault analysis for weak and reused passwords. +- Password-protected encrypted backup and restore, written wherever you choose. +- Excluded from Android cloud backup and device-to-device transfer. +- Drag & drop reordering, swipe to delete, category separation. +- Dark mode and 17 languages. +- Open source. + +## 🔐 Security model: + +- The vault is a Room database encrypted with SQLCipher, stored only on the device. +- It is unlocked with device biometrics or the screen lock, and re-locks automatically after a + configurable idle period. +- Backups are encrypted with AES-256-GCM under a key derived from a password you choose + (PBKDF2-HMAC-SHA512), written through the system file picker. +- The app performs no network requests of its own and has no server or account. + +> **Known limitation, being addressed in 5.7.0:** the database key is currently a single +> build-time constant shared by every install, so the database is encrypted against someone who +> obtains the file alone, and not against someone who also has the APK. 5.7.0 replaces it with a +> per-install random key wrapped by an Android Keystore key and unlocked via a biometric +> `CryptoObject`, with a password-based recovery slot. + +## 🌐 Network and privacy: + +The app itself makes no network calls, but it bundles Google Firebase (Crashlytics, Analytics, +Performance Monitoring) and Google Play in-app updates. These require the `INTERNET` permission +and send crash diagnostics, usage events, performance traces and device/installation identifiers +— including an advertising ID — to Google. They never have access to vault contents. See +[Privacy Policy.md](Privacy%20Policy.md). + ## 🌎 Released Android Application: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 062bc3e..a4f6a17 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.bhardwaj.passkey" minSdk = 28 targetSdk = 36 - versionCode = 44 - versionName = "5.5.2" + versionCode = 45 + versionName = "5.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5fa2e9c..f3e1f53 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -139,4 +139,7 @@ مفتاح تسجيل الدخول + يحفظ PassKey بيانات تسجيل الدخول وكلمات المرور والملاحظات الخاصة الأخرى في قاعدة بيانات مشفّرة على جهازك.\n\nخزنتك لا تغادر هاتفك أبدًا. لا يوجد حساب ولا مزامنة ولا خادم لـ PassKey: التطبيق لا يجري أي طلبات شبكة من تلقاء نفسه، ولا تُرسل مدخلاتك إلى أي مكان.\n\nقاعدة البيانات مشفّرة بواسطة SQLCipher وتُفتح ببصمتك أو قفل شاشة جهازك. تُقفل الخزنة تلقائيًا عند مغادرة التطبيق، ولقطات الشاشة محظورة، وكلمات المرور المنسوخة تُعلَّم كحساسة وتُمسح من الحافظة بعد وقت قصير.\n\nيمكنك تنظيم المدخلات في فئات، وإعادة ترتيبها بالسحب، وإنشاء كلمات مرور قوية، وفحص خزنتك بحثًا عن كلمات مرور ضعيفة أو مكررة. تُصدَّر النسخ الاحتياطية كملف واحد مشفّر بكلمة مرور تختارها، ويمكن استعادتها على أي جهاز.\n\nPassKey مجاني ومفتوح المصدر، طوّره Aditya Bhardwaj. الشيفرة المصدرية الكاملة متاحة على GitHub. + تبقى خزنتك على جهازك.\n\nما لا نجمعه أبدًا: لا يجمع PassKey ولا ينقل ولا يخزّن كلمات المرور أو أسماء المستخدمين أو الملاحظات أو أي مدخلات تحفظها. توجد فقط في قاعدة بيانات مشفّرة على هاتفك. لا يمكننا قراءتها ولا استعادتها لك.\n\nما يرسله التطبيق: يتضمن PassKey خدمات Google Firebase Crashlytics وAnalytics وPerformance Monitoring، وتحديثات Google Play داخل التطبيق. ترسل هذه الخدمات إلى Google تشخيصات الأعطال وأحداث الاستخدام وبيانات الأداء ومعرّفات الجهاز والتثبيت بما في ذلك معرّف إعلاني. لا تصل هذه الخدمات إلى محتوى خزنتك إطلاقًا. بسبب هذه الخدمات يحتاج التطبيق إلى إذن الإنترنت، وتخضع معالجتها للبيانات لسياسة خصوصية Google.\n\nالنسخ الاحتياطية: عند التصدير يُشفَّر الملف بكلمة مرور تختارها ويُحفظ حيث تحدد للنظام. تصبح مسؤولية هذا الملف عليك بعد ذلك؛ من يحصل على الملف وكلمة مروره معًا يستطيع قراءته. نحن لا نحتفظ بأي نسخة.\n\nنسخ الجهاز الاحتياطي: PassKey مستثنى من النسخ الاحتياطي السحابي في Android ومن النقل بين الأجهزة، لذا لا تُرفع خزنتك إلى حساب Google الخاص بك.\n\nالتغييرات: قد تُحدَّث هذه السياسة؛ وستُذكر التغييرات الجوهرية في ملاحظات إصدار التطبيق.\n\nللاستفسار: تواصل عبر yrkkh.cclub@gmail.com. + باستخدامك PassKey فإنك توافق على ما يلي.\n\n1. PassKey برنامج مجاني مفتوح المصدر طوّره Aditya Bhardwaj.\n\n2. تُخزَّن خزنتك على جهازك فقط. لا توجد نسخة على أي خادم، وبالتالي لا توجد وسيلة لاستعادة بياناتك إذا فقدت جهازك أو أزلت التطبيق أو نسيت كلمة مرور النسخة الاحتياطية.\n\n3. الاحتفاظ بالنسخ الاحتياطية مسؤوليتك. صدّرها بانتظام واحفظ كلمة مرور النسخة في مكان آمن ومنفصل.\n\n4. يعتمد التطبيق على أمان البصمة أو قفل الشاشة في جهازك. إذا كان هذا القفل ضعيفًا أو مُزالًا أو مخترقًا، تقل الحماية التي يوفرها PassKey بالقدر نفسه.\n\n5. يتضمن PassKey خدمات Google Firebase وGoogle Play كما هو موضح في سياسة الخصوصية.\n\n6. يُقدَّم التطبيق \"كما هو\" دون أي ضمان صريح أو ضمني، بما في ذلك القابلية للتسويق والملاءمة لغرض معين.\n\n7. إلى الحد الذي يسمح به القانون، لا يتحمل المطوّر مسؤولية أي أضرار ناتجة عن استخدام التطبيق، بما في ذلك فقدان البيانات.\n\n8. يجوز تعديل التطبيق أو إيقافه في أي وقت. \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index f8a9cfc..1da4261 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -135,4 +135,7 @@ চাবি লগইন + PassKey আপনার লগইন, পাসওয়ার্ড এবং অন্যান্য ব্যক্তিগত নোট আপনার ডিভাইসের একটি এনক্রিপ্ট করা ডেটাবেসে সংরক্ষণ করে।\n\nআপনার ভল্ট কখনও ফোন ছেড়ে যায় না। কোনো অ্যাকাউন্ট নেই, সিঙ্ক নেই, PassKey-এর কোনো সার্ভারও নেই: অ্যাপটি নিজে থেকে কোনো নেটওয়ার্ক অনুরোধ করে না এবং আপনার এন্ট্রি কোথাও পাঠানো হয় না।\n\nডেটাবেসটি SQLCipher দিয়ে এনক্রিপ্ট করা এবং আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন লক দিয়ে খোলে। অ্যাপ ছাড়লেই ভল্ট স্বয়ংক্রিয়ভাবে আবার লক হয়, স্ক্রিনশট বন্ধ, এবং কপি করা পাসওয়ার্ড সংবেদনশীল হিসেবে চিহ্নিত হয়ে অল্প পরেই ক্লিপবোর্ড থেকে মুছে যায়।\n\nআপনি এন্ট্রি বিভাগে সাজাতে, টেনে ক্রম বদলাতে, শক্তিশালী পাসওয়ার্ড তৈরি করতে এবং দুর্বল বা পুনরায় ব্যবহৃত পাসওয়ার্ড পরীক্ষা করতে পারেন। ব্যাকআপ একটি ফাইল হিসেবে রপ্তানি হয়, আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট করা, এবং যেকোনো ডিভাইসে পুনরুদ্ধার করা যায়।\n\nPassKey বিনামূল্যে ও ওপেন সোর্স, তৈরি করেছেন Aditya Bhardwaj। সম্পূর্ণ সোর্স কোড GitHub-এ পাওয়া যায়। + আপনার ভল্ট আপনার ডিভাইসেই থাকে।\n\nআমরা যা কখনও সংগ্রহ করি না: PassKey আপনার সংরক্ষিত পাসওয়ার্ড, ব্যবহারকারীর নাম, নোট বা অন্য কোনো এন্ট্রি সংগ্রহ, প্রেরণ বা সঞ্চয় করে না। সেগুলি কেবল আপনার ফোনের এনক্রিপ্ট করা ডেটাবেসে থাকে। আমরা সেগুলি পড়তেও পারি না, আপনার জন্য ফিরিয়ে আনতেও পারি না।\n\nঅ্যাপ যা পাঠায়: PassKey-তে Google Firebase Crashlytics, Analytics ও Performance Monitoring এবং Google Play ইন-অ্যাপ আপডেট অন্তর্ভুক্ত। এগুলি ক্র্যাশ ডায়াগনস্টিক, ব্যবহারের ঘটনা, কর্মক্ষমতার তথ্য এবং বিজ্ঞাপন আইডিসহ ডিভাইস ও ইনস্টলেশন শনাক্তকারী Google-কে পাঠায়। আপনার ভল্টের বিষয়বস্তুতে এদের কখনও প্রবেশাধিকার নেই। এই পরিষেবাগুলির কারণে অ্যাপটির ইন্টারনেট অনুমতি প্রয়োজন, এবং তাদের তথ্য ব্যবস্থাপনা Google-এর গোপনীয়তা নীতি দ্বারা পরিচালিত।\n\nব্যাকআপ: রপ্তানির সময় ফাইলটি আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট হয় এবং আপনি সিস্টেমকে যেখানে বলবেন সেখানে সংরক্ষিত হয়। এরপর সেই ফাইলের দায়িত্ব আপনার; যার কাছে ফাইল ও পাসওয়ার্ড দুটোই থাকবে সে পড়তে পারবে। আমরা কোনো অনুলিপি রাখি না।\n\nডিভাইস ব্যাকআপ: PassKey Android ক্লাউড ব্যাকআপ ও ডিভাইস-টু-ডিভাইস স্থানান্তর থেকে বাদ, তাই আপনার ভল্ট আপনার Google অ্যাকাউন্টে আপলোড হয় না।\n\nপরিবর্তন: এই নীতি হালনাগাদ হতে পারে; গুরুত্বপূর্ণ পরিবর্তন অ্যাপের রিলিজ নোটে জানানো হবে।\n\nপ্রশ্ন: যোগাযোগ করুন yrkkh.cclub@gmail.com। + PassKey ব্যবহার করে আপনি নিম্নলিখিত বিষয়ে সম্মত হচ্ছেন।\n\n১. PassKey একটি বিনামূল্যের, ওপেন-সোর্স সফটওয়্যার যা Aditya Bhardwaj তৈরি করেছেন।\n\n২. আপনার ভল্ট কেবল আপনার ডিভাইসে সংরক্ষিত। সার্ভারে কোনো অনুলিপি নেই, তাই ডিভাইস হারালে, অ্যাপ আনইনস্টল করলে বা ব্যাকআপ পাসওয়ার্ড ভুলে গেলে ডেটা ফেরানোর কোনো উপায় নেই।\n\n৩. ব্যাকআপ রাখা আপনার দায়িত্ব। নিয়মিত রপ্তানি করুন এবং ব্যাকআপ পাসওয়ার্ড নিরাপদ ও আলাদা জায়গায় রাখুন।\n\n৪. অ্যাপটি আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন-লক নিরাপত্তার উপর নির্ভরশীল। সেই লক দুর্বল, সরানো বা আপসকৃত হলে PassKey-এর সুরক্ষাও সেই অনুপাতে কমে যায়।\n\n৫. PassKey-তে গোপনীয়তা নীতিতে বর্ণিত Google Firebase ও Google Play পরিষেবা অন্তর্ভুক্ত।\n\n৬. অ্যাপ্লিকেশনটি \"যেমন আছে\" সরবরাহ করা হয়, কোনো প্রকার প্রকাশ্য বা অন্তর্নিহিত ওয়ারেন্টি ছাড়াই, যার মধ্যে বাণিজ্যিক উপযোগিতা ও নির্দিষ্ট উদ্দেশ্যের উপযুক্ততা অন্তর্ভুক্ত।\n\n৭. আইন অনুমোদিত সীমা পর্যন্ত, ডেভেলপার অ্যাপ্লিকেশন ব্যবহারের ফলে উদ্ভূত কোনো ক্ষতির জন্য, তথ্য হারানো সহ, দায়ী নন।\n\n৮. অ্যাপ্লিকেশনটি যেকোনো সময় পরিবর্তন বা বন্ধ করা হতে পারে। diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 117f6e4..58b1e66 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -136,4 +136,7 @@ zugangsdaten anmeldung + PassKey speichert deine Zugangsdaten, Passwörter und privaten Notizen in einer verschlüsselten Datenbank auf deinem Gerät.\n\nDein Tresor verlässt dein Telefon nie. Es gibt kein Konto, keine Synchronisierung und keinen PassKey-Server: Die App stellt keine eigenen Netzwerkanfragen, und deine Einträge werden nirgendwohin übertragen.\n\nDie Datenbank ist mit SQLCipher verschlüsselt und wird mit der Biometrie oder Bildschirmsperre deines Geräts entsperrt. Der Tresor sperrt sich automatisch, wenn du die App verlässt, Screenshots sind blockiert, und kopierte Passwörter werden als vertraulich markiert und kurz darauf aus der Zwischenablage gelöscht.\n\nDu kannst Einträge in Kategorien ordnen, sie per Ziehen neu anordnen, starke Passwörter erzeugen und deinen Tresor auf schwache oder mehrfach verwendete Passwörter prüfen. Backups werden als einzelne Datei exportiert, verschlüsselt mit einem Passwort deiner Wahl, und lassen sich auf jedem Gerät wiederherstellen.\n\nPassKey ist kostenlos und quelloffen, entwickelt von Aditya Bhardwaj. Der vollständige Quellcode ist auf GitHub verfügbar. + Dein Tresor bleibt auf deinem Gerät.\n\nWas wir nie erfassen: PassKey erfasst, überträgt oder speichert keine der Passwörter, Benutzernamen, Notizen oder sonstigen Einträge, die du sicherst. Sie liegen ausschließlich in einer verschlüsselten Datenbank auf deinem Telefon. Wir können sie weder lesen noch für dich wiederherstellen.\n\nWas die App sendet: PassKey enthält Google Firebase Crashlytics, Analytics und Performance Monitoring sowie Google Play In-App-Updates. Diese senden Absturzdiagnosen, Nutzungsereignisse, Leistungsdaten sowie Geräte- und Installationskennungen einschließlich einer Werbe-ID an Google. Sie haben nie Zugriff auf den Inhalt deines Tresors. Wegen dieser Dienste benötigt die App die Internetberechtigung; deren Datenverarbeitung unterliegt der Datenschutzerklärung von Google.\n\nBackups: Beim Export wird die Datei mit einem Passwort deiner Wahl verschlüsselt und dort abgelegt, wo du es dem System vorgibst. Diese Datei liegt dann in deiner Verantwortung; wer Datei und Passwort besitzt, kann sie lesen. Wir behalten keine Kopie.\n\nGerätesicherung: PassKey ist von der Android-Cloud-Sicherung und der Geräteübertragung ausgeschlossen, dein Tresor wird also nicht in dein Google-Konto hochgeladen.\n\nÄnderungen: Diese Erklärung kann aktualisiert werden; wesentliche Änderungen werden in den Versionshinweisen der App vermerkt.\n\nFragen: Kontakt yrkkh.cclub@gmail.com. + Mit der Nutzung von PassKey stimmst du Folgendem zu.\n\n1. PassKey ist kostenlose, quelloffene Software, entwickelt von Aditya Bhardwaj.\n\n2. Dein Tresor wird nur auf deinem Gerät gespeichert. Es gibt keine serverseitige Kopie und damit keine Möglichkeit, deine Daten wiederherzustellen, wenn du dein Gerät verlierst, die App deinstallierst oder ein Backup-Passwort vergisst.\n\n3. Backups liegen in deiner Verantwortung. Exportiere sie regelmäßig und bewahre das Backup-Passwort sicher und getrennt auf.\n\n4. Die App stützt sich auf die biometrische Sicherung oder Bildschirmsperre deines Geräts. Ist diese schwach, entfernt oder kompromittiert, verringert sich der Schutz durch PassKey entsprechend.\n\n5. PassKey enthält Google-Firebase- und Google-Play-Dienste wie in der Datenschutzerklärung beschrieben.\n\n6. Die Anwendung wird \"wie besehen\" bereitgestellt, ohne jegliche ausdrückliche oder stillschweigende Gewährleistung, einschließlich Marktgängigkeit und Eignung für einen bestimmten Zweck.\n\n7. Soweit gesetzlich zulässig, haftet der Entwickler nicht für Schäden aus der Nutzung der Anwendung, einschließlich Datenverlust.\n\n8. Die Anwendung kann jederzeit geändert oder eingestellt werden. \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2547279..78136fd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -136,4 +136,7 @@ llave acceso + PassKey guarda tus inicios de sesión, contraseñas y otras notas privadas en una base de datos cifrada en tu dispositivo.\n\nTu caja fuerte nunca sale del teléfono. No hay cuenta, ni sincronización, ni servidor de PassKey: la aplicación no realiza peticiones de red propias y tus entradas nunca se transmiten a ninguna parte.\n\nLa base de datos se cifra con SQLCipher y se desbloquea con la biometría o el bloqueo de pantalla de tu dispositivo. La caja fuerte se vuelve a bloquear automáticamente al salir de la aplicación, las capturas de pantalla están bloqueadas y las contraseñas copiadas se marcan como sensibles y se borran del portapapeles poco después.\n\nPuedes organizar las entradas en categorías, reordenarlas arrastrando, generar contraseñas seguras y revisar tu caja fuerte en busca de contraseñas débiles o repetidas. Las copias de seguridad se exportan como un único archivo cifrado con una contraseña que tú eliges y pueden restaurarse en cualquier dispositivo.\n\nPassKey es gratuita y de código abierto, desarrollada por Aditya Bhardwaj. El código fuente completo está disponible en GitHub. + Tu caja fuerte permanece en tu dispositivo.\n\nLo que nunca recopilamos: PassKey no recopila, transmite ni almacena las contraseñas, nombres de usuario, notas ni ninguna otra entrada que guardes. Solo existen en una base de datos cifrada en tu teléfono. No podemos leerlas ni recuperarlas por ti.\n\nLo que sí envía la aplicación: PassKey incluye Google Firebase Crashlytics, Analytics y Performance Monitoring, y las actualizaciones integradas de Google Play. Estos servicios envían diagnósticos de fallos, eventos de uso, trazas de rendimiento e identificadores de dispositivo e instalación, incluido un identificador de publicidad, a Google. Nunca tienen acceso al contenido de tu caja fuerte. Por estos servicios la aplicación requiere el permiso de internet, y su tratamiento de datos se rige por la política de privacidad de Google.\n\nCopias de seguridad: al exportar una copia, el archivo se cifra con una contraseña que tú eliges y se guarda donde le indiques al sistema. Ese archivo queda bajo tu responsabilidad; cualquiera que obtenga el archivo y su contraseña podrá leerlo. No conservamos ninguna copia.\n\nCopia del dispositivo: PassKey está excluida de la copia de seguridad en la nube de Android y de la transferencia entre dispositivos, así que tu caja fuerte no se sube a tu cuenta de Google.\n\nCambios: esta política puede actualizarse; los cambios importantes se indicarán en las notas de versión de la aplicación.\n\nDudas: escribe a yrkkh.cclub@gmail.com. + Al usar PassKey aceptas lo siguiente.\n\n1. PassKey es software gratuito y de código abierto desarrollado por Aditya Bhardwaj.\n\n2. Tu caja fuerte se almacena únicamente en tu dispositivo. No hay copia en servidor y, por tanto, no existe forma de recuperar tus datos si pierdes el dispositivo, desinstalas la aplicación u olvidas una contraseña de copia de seguridad.\n\n3. Mantener copias de seguridad es responsabilidad tuya. Expórtalas con regularidad y guarda la contraseña de la copia en un lugar seguro y separado.\n\n4. La aplicación depende de la seguridad biométrica o del bloqueo de pantalla de tu dispositivo. Si ese bloqueo es débil, se elimina o queda comprometido, la protección que ofrece PassKey se reduce en consecuencia.\n\n5. PassKey incluye servicios de Google Firebase y Google Play según se describe en la Política de Privacidad.\n\n6. La aplicación se proporciona \"tal cual\", sin garantía de ningún tipo, expresa o implícita, incluidas las de comerciabilidad e idoneidad para un fin determinado.\n\n7. En la medida permitida por la ley, el desarrollador no se hace responsable de los daños derivados del uso de la aplicación, incluida la pérdida de datos.\n\n8. La aplicación puede modificarse o descontinuarse en cualquier momento. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c819cbf..d246b44 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -136,4 +136,7 @@ identifiant connexion + PassKey enregistre vos identifiants, mots de passe et autres notes privées dans une base de données chiffrée sur votre appareil.\n\nVotre coffre ne quitte jamais votre téléphone. Il n\'y a ni compte, ni synchronisation, ni serveur PassKey : l\'application n\'effectue aucune requête réseau de son propre fait et vos entrées ne sont transmises nulle part.\n\nLa base de données est chiffrée avec SQLCipher et déverrouillée par la biométrie ou le verrouillage d\'écran de votre appareil. Le coffre se reverrouille automatiquement lorsque vous quittez l\'application, les captures d\'écran sont bloquées, et les mots de passe copiés sont marqués comme sensibles puis effacés du presse-papiers peu après.\n\nVous pouvez classer vos entrées par catégories, les réorganiser par glissement, générer des mots de passe robustes et vérifier votre coffre à la recherche de mots de passe faibles ou réutilisés. Les sauvegardes sont exportées sous forme d\'un fichier unique chiffré avec un mot de passe de votre choix, restaurable sur n\'importe quel appareil.\n\nPassKey est gratuite et open source, développée par Aditya Bhardwaj. Le code source complet est disponible sur GitHub. + Votre coffre reste sur votre appareil.\n\nCe que nous ne collectons jamais : PassKey ne collecte, ne transmet et ne stocke aucun des mots de passe, identifiants, notes ou autres entrées que vous enregistrez. Ils n\'existent que dans une base de données chiffrée sur votre téléphone. Nous ne pouvons ni les lire, ni les récupérer pour vous.\n\nCe que l\'application envoie : PassKey intègre Google Firebase Crashlytics, Analytics et Performance Monitoring, ainsi que les mises à jour intégrées de Google Play. Ces services envoient à Google des diagnostics de plantage, des événements d\'utilisation, des traces de performance et des identifiants d\'appareil et d\'installation, dont un identifiant publicitaire. Ils n\'ont jamais accès au contenu de votre coffre. En raison de ces services, l\'application requiert l\'autorisation d\'accès à Internet, et leur traitement des données relève de la politique de confidentialité de Google.\n\nSauvegardes : lorsque vous exportez une sauvegarde, le fichier est chiffré avec un mot de passe de votre choix et enregistré à l\'emplacement que vous indiquez au système. Ce fichier relève ensuite de votre responsabilité ; quiconque obtient à la fois le fichier et son mot de passe peut le lire. Nous n\'en conservons aucune copie.\n\nSauvegarde de l\'appareil : PassKey est exclue de la sauvegarde cloud Android et du transfert entre appareils, votre coffre n\'est donc pas envoyé vers votre compte Google.\n\nModifications : cette politique peut évoluer ; les changements importants seront signalés dans les notes de version.\n\nQuestions : contactez yrkkh.cclub@gmail.com. + En utilisant PassKey, vous acceptez ce qui suit.\n\n1. PassKey est un logiciel gratuit et open source développé par Aditya Bhardwaj.\n\n2. Votre coffre est stocké uniquement sur votre appareil. Il n\'existe aucune copie côté serveur, et donc aucun moyen de récupérer vos données si vous perdez votre appareil, désinstallez l\'application ou oubliez un mot de passe de sauvegarde.\n\n3. La conservation des sauvegardes vous incombe. Exportez-les régulièrement et conservez le mot de passe de sauvegarde en lieu sûr et séparé.\n\n4. L\'application repose sur la sécurité biométrique ou le verrouillage d\'écran de votre appareil. Si ce verrouillage est faible, supprimé ou compromis, la protection offerte par PassKey est réduite d\'autant.\n\n5. PassKey intègre les services Google Firebase et Google Play décrits dans la politique de confidentialité.\n\n6. L\'application est fournie \"en l\'état\", sans garantie d\'aucune sorte, expresse ou implicite, y compris de qualité marchande et d\'adéquation à un usage particulier.\n\n7. Dans la mesure permise par la loi, le développeur n\'est pas responsable des dommages résultant de l\'utilisation de l\'application, y compris la perte de données.\n\n8. L\'application peut être modifiée ou abandonnée à tout moment. diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index b7e07d6..b2e33fe 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -134,4 +134,7 @@ ચાવી લૉગિન + PassKey તમારા લૉગિન, પાસવર્ડ અને અન્ય ખાનગી નોંધો તમારા ઉપકરણ પરના એન્ક્રિપ્ટેડ ડેટાબેઝમાં સાચવે છે.\n\nતમારી વૉલ્ટ ક્યારેય તમારા ફોનની બહાર જતી નથી. કોઈ ખાતું નથી, સિંક નથી, PassKey નું કોઈ સર્વર પણ નથી: ઍપ પોતે કોઈ નેટવર્ક વિનંતી કરતી નથી અને તમારી નોંધો ક્યાંય મોકલાતી નથી.\n\nડેટાબેઝ SQLCipher થી એન્ક્રિપ્ટ થયેલો છે અને તમારા ઉપકરણના બાયોમેટ્રિક કે સ્ક્રીન લૉકથી ખૂલે છે. ઍપ છોડતાં જ વૉલ્ટ આપોઆપ ફરી લૉક થાય છે, સ્ક્રીનશૉટ રોકાયેલા છે, અને કૉપિ કરેલા પાસવર્ડ સંવેદનશીલ તરીકે ચિહ્નિત થઈને થોડી વારમાં ક્લિપબોર્ડમાંથી દૂર થાય છે.\n\nતમે નોંધોને શ્રેણીઓમાં ગોઠવી શકો, ખેંચીને ક્રમ બદલી શકો, મજબૂત પાસવર્ડ બનાવી શકો અને નબળા કે પુનરાવર્તિત પાસવર્ડ તપાસી શકો. બૅકઅપ એક જ ફાઇલ તરીકે નિકાસ થાય છે, તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટેડ, અને કોઈ પણ ઉપકરણ પર પુનઃસ્થાપિત થઈ શકે છે.\n\nPassKey મફત અને ઓપન સોર્સ છે, Aditya Bhardwaj દ્વારા વિકસાવાયેલ. સંપૂર્ણ સ્રોત કોડ GitHub પર ઉપલબ્ધ છે. + તમારી વૉલ્ટ તમારા ઉપકરણ પર જ રહે છે.\n\nઅમે ક્યારેય શું એકત્ર કરતા નથી: PassKey તમે સાચવેલા પાસવર્ડ, વપરાશકર્તાનામ, નોંધો કે અન્ય કોઈ પણ નોંધ એકત્ર, પ્રસારિત કે સંગ્રહિત કરતું નથી. તે માત્ર તમારા ફોનના એન્ક્રિપ્ટેડ ડેટાબેઝમાં રહે છે. અમે તે વાંચી શકતા નથી અને તમારા માટે પાછી મેળવી પણ શકતા નથી.\n\nઍપ શું મોકલે છે: PassKey માં Google Firebase Crashlytics, Analytics અને Performance Monitoring તથા Google Play ઇન-ઍપ અપડેટ સામેલ છે. આ ક્રેશ નિદાન, વપરાશ ઘટનાઓ, કામગીરી માહિતી અને જાહેરાત ID સહિત ઉપકરણ તથા ઇન્સ્ટોલેશન ઓળખકર્તાઓ Google ને મોકલે છે. તેમને તમારી વૉલ્ટની સામગ્રી સુધી ક્યારેય પ્રવેશ મળતો નથી. આ સેવાઓને કારણે ઍપને ઇન્ટરનેટ પરવાનગી જોઈએ છે, અને તેમનું ડેટા સંચાલન Google ની ગોપનીયતા નીતિ હેઠળ આવે છે.\n\nબૅકઅપ: નિકાસ વખતે ફાઇલ તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટ થાય છે અને તમે સિસ્ટમને જ્યાં કહો ત્યાં સચવાય છે. ત્યાર બાદ તે ફાઇલની જવાબદારી તમારી છે; જેની પાસે ફાઇલ અને પાસવર્ડ બંને હોય તે તેને વાંચી શકે છે. અમે કોઈ નકલ રાખતા નથી.\n\nઉપકરણ બૅકઅપ: PassKey ને Android ક્લાઉડ બૅકઅપ અને ઉપકરણ-થી-ઉપકરણ સ્થાનાંતરણમાંથી બાકાત રાખ્યું છે, તેથી તમારી વૉલ્ટ તમારા Google ખાતામાં અપલોડ થતી નથી.\n\nફેરફારો: આ નીતિ અપડેટ થઈ શકે છે; મહત્ત્વના ફેરફારો ઍપની રિલીઝ નોંધમાં જણાવાશે.\n\nપ્રશ્નો: સંપર્ક yrkkh.cclub@gmail.com. + PassKey વાપરીને તમે નીચેની બાબતો સાથે સંમત થાઓ છો.\n\n1. PassKey એ Aditya Bhardwaj દ્વારા વિકસાવાયેલ મફત, ઓપન-સોર્સ સોફ્ટવેર છે.\n\n2. તમારી વૉલ્ટ માત્ર તમારા ઉપકરણ પર સંગ્રહિત છે. સર્વર પર કોઈ નકલ નથી, તેથી ઉપકરણ ખોવાય, ઍપ દૂર કરો કે બૅકઅપ પાસવર્ડ ભૂલી જાઓ તો ડેટા પાછો મેળવવાનો કોઈ માર્ગ નથી.\n\n3. બૅકઅપ રાખવાની જવાબદારી તમારી છે. નિયમિત નિકાસ કરો અને બૅકઅપ પાસવર્ડ સુરક્ષિત, અલગ જગ્યાએ રાખો.\n\n4. ઍપ તમારા ઉપકરણની બાયોમેટ્રિક કે સ્ક્રીન-લૉક સુરક્ષા પર આધારિત છે. જો તે લૉક નબળો, દૂર કરેલો કે ભંગ થયેલો હોય, તો PassKey નું રક્ષણ તે પ્રમાણમાં ઘટે છે.\n\n5. PassKey માં ગોપનીયતા નીતિમાં વર્ણવેલ Google Firebase અને Google Play સેવાઓ સામેલ છે.\n\n6. આ એપ્લિકેશન \"જેમ છે તેમ\" પૂરી પાડવામાં આવે છે, કોઈ પણ સ્પષ્ટ કે ગર્ભિત વોરંટી વિના, જેમાં વેપારયોગ્યતા અને ચોક્કસ હેતુ માટેની યોગ્યતા સામેલ છે.\n\n7. કાયદા દ્વારા માન્ય હદ સુધી, ડેવલપર એપ્લિકેશનના ઉપયોગથી થતા કોઈ પણ નુકસાન માટે, ડેટા ગુમાવવા સહિત, જવાબદાર નથી.\n\n8. એપ્લિકેશન કોઈ પણ સમયે સુધારી કે બંધ કરી શકાય છે. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 1c90f86..94bb73a 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -135,4 +135,7 @@ कुंजी लॉगिन + PassKey आपके लॉगिन, पासवर्ड और अन्य निजी नोट्स को आपके डिवाइस पर एक एन्क्रिप्टेड डेटाबेस में सहेजता है।\n\nआपकी वॉल्ट कभी आपके फ़ोन से बाहर नहीं जाती। न कोई खाता है, न सिंक, न PassKey का कोई सर्वर: ऐप अपनी ओर से कोई नेटवर्क अनुरोध नहीं करता और आपकी प्रविष्टियाँ कहीं नहीं भेजी जातीं।\n\nडेटाबेस SQLCipher से एन्क्रिप्ट किया गया है और आपके डिवाइस के बायोमेट्रिक या स्क्रीन लॉक से खुलता है। ऐप छोड़ते ही वॉल्ट अपने आप फिर लॉक हो जाती है, स्क्रीनशॉट रोक दिए गए हैं, और कॉपी किए गए पासवर्ड संवेदनशील चिह्नित होकर कुछ ही देर में क्लिपबोर्ड से हट जाते हैं।\n\nआप प्रविष्टियों को श्रेणियों में रख सकते हैं, खींचकर क्रम बदल सकते हैं, मज़बूत पासवर्ड बना सकते हैं और कमज़ोर या दोहराए गए पासवर्ड जाँच सकते हैं। बैकअप एक ही फ़ाइल के रूप में निर्यात होता है, जो आपके चुने पासवर्ड से एन्क्रिप्टेड होता है और किसी भी डिवाइस पर बहाल किया जा सकता है।\n\nPassKey निःशुल्क और ओपन सोर्स है, जिसे Aditya Bhardwaj ने बनाया है। पूरा स्रोत कोड GitHub पर उपलब्ध है। + आपकी वॉल्ट आपके डिवाइस पर ही रहती है।\n\nहम कभी क्या एकत्र नहीं करते: PassKey आपके सहेजे गए पासवर्ड, उपयोगकर्ता नाम, नोट्स या किसी भी प्रविष्टि को एकत्र, प्रेषित या संग्रहीत नहीं करता। ये केवल आपके फ़ोन के एन्क्रिप्टेड डेटाबेस में रहते हैं। हम न इन्हें पढ़ सकते हैं, न आपके लिए वापस ला सकते हैं।\n\nऐप क्या भेजता है: PassKey में Google Firebase Crashlytics, Analytics और Performance Monitoring तथा Google Play इन-ऐप अपडेट शामिल हैं। ये क्रैश डायग्नोस्टिक्स, उपयोग इवेंट, प्रदर्शन डेटा और विज्ञापन आईडी सहित डिवाइस व इंस्टॉलेशन पहचानकर्ता Google को भेजते हैं। इनकी पहुँच आपकी वॉल्ट की सामग्री तक कभी नहीं होती। इन सेवाओं के कारण ऐप को इंटरनेट अनुमति चाहिए, और इनका डेटा प्रबंधन Google की गोपनीयता नीति से संचालित होता है।\n\nबैकअप: निर्यात करते समय फ़ाइल आपके चुने पासवर्ड से एन्क्रिप्ट होती है और वहीं सहेजी जाती है जहाँ आप सिस्टम को बताते हैं। उसके बाद उस फ़ाइल की ज़िम्मेदारी आपकी है; जिसके पास फ़ाइल और पासवर्ड दोनों हों वह उसे पढ़ सकता है। हम कोई प्रति नहीं रखते।\n\nडिवाइस बैकअप: PassKey को Android क्लाउड बैकअप और डिवाइस-टू-डिवाइस ट्रांसफ़र से बाहर रखा गया है, इसलिए आपकी वॉल्ट आपके Google खाते में अपलोड नहीं होती।\n\nबदलाव: यह नीति अद्यतन हो सकती है; महत्वपूर्ण बदलाव ऐप के रिलीज़ नोट्स में बताए जाएँगे।\n\nप्रश्न: संपर्क करें yrkkh.cclub@gmail.com। + PassKey का उपयोग करके आप निम्नलिखित से सहमत होते हैं।\n\n1. PassKey एक निःशुल्क, ओपन-सोर्स सॉफ़्टवेयर है जिसे Aditya Bhardwaj ने विकसित किया है।\n\n2. आपकी वॉल्ट केवल आपके डिवाइस पर संग्रहीत होती है। सर्वर पर कोई प्रति नहीं है, इसलिए डिवाइस खोने, ऐप हटाने या बैकअप पासवर्ड भूलने पर डेटा वापस पाने का कोई उपाय नहीं है।\n\n3. बैकअप रखना आपकी ज़िम्मेदारी है। इन्हें नियमित रूप से निर्यात करें और बैकअप पासवर्ड को सुरक्षित, अलग जगह रखें।\n\n4. ऐप आपके डिवाइस की बायोमेट्रिक या स्क्रीन-लॉक सुरक्षा पर निर्भर करता है। यदि वह लॉक कमज़ोर, हटाया हुआ या असुरक्षित है, तो PassKey द्वारा दी गई सुरक्षा उसी अनुपात में घट जाती है।\n\n5. PassKey में गोपनीयता नीति में वर्णित Google Firebase और Google Play सेवाएँ शामिल हैं।\n\n6. यह एप्लिकेशन \"जैसा है\" प्रदान किया जाता है, बिना किसी व्यक्त या निहित वारंटी के, जिसमें व्यापारिकता और किसी विशेष उद्देश्य के लिए उपयुक्तता शामिल है।\n\n7. कानून द्वारा अनुमत सीमा तक, डेवलपर एप्लिकेशन के उपयोग से उत्पन्न किसी भी क्षति, जिसमें डेटा हानि शामिल है, के लिए उत्तरदायी नहीं है।\n\n8. एप्लिकेशन को किसी भी समय संशोधित या बंद किया जा सकता है। \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b92df5a..48b2a51 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -136,4 +136,7 @@ chiave accesso + PassKey salva credenziali, password e altre note private in un database cifrato sul tuo dispositivo.\n\nLa tua cassaforte non lascia mai il telefono. Non c\'è alcun account, nessuna sincronizzazione e nessun server PassKey: l\'app non effettua richieste di rete proprie e le tue voci non vengono trasmesse da nessuna parte.\n\nIl database è cifrato con SQLCipher e si sblocca con la biometria o il blocco schermo del dispositivo. La cassaforte si riblocca automaticamente quando esci dall\'app, gli screenshot sono bloccati e le password copiate vengono contrassegnate come sensibili ed eliminate dagli appunti poco dopo.\n\nPuoi organizzare le voci in categorie, riordinarle trascinando, generare password robuste e controllare la cassaforte alla ricerca di password deboli o riutilizzate. I backup vengono esportati come un unico file cifrato con una password scelta da te e si possono ripristinare su qualsiasi dispositivo.\n\nPassKey è gratuita e open source, sviluppata da Aditya Bhardwaj. Il codice sorgente completo è disponibile su GitHub. + La tua cassaforte resta sul tuo dispositivo.\n\nCosa non raccogliamo mai: PassKey non raccoglie, trasmette né memorizza le password, i nomi utente, le note o qualsiasi altra voce che salvi. Esistono soltanto in un database cifrato sul tuo telefono. Non possiamo leggerle né recuperarle per te.\n\nCosa invia l\'app: PassKey include Google Firebase Crashlytics, Analytics e Performance Monitoring e gli aggiornamenti in-app di Google Play. Questi servizi inviano a Google diagnostiche di arresto anomalo, eventi di utilizzo, tracce prestazionali e identificatori di dispositivo e installazione, compreso un ID pubblicitario. Non hanno mai accesso al contenuto della cassaforte. A causa di questi servizi l\'app richiede l\'autorizzazione a Internet, e il loro trattamento dei dati è regolato dall\'informativa privacy di Google.\n\nBackup: quando esporti un backup, il file viene cifrato con una password scelta da te e salvato dove indichi al sistema. Quel file è poi sotto la tua responsabilità; chiunque ottenga sia il file sia la password può leggerlo. Non ne conserviamo copia.\n\nBackup del dispositivo: PassKey è esclusa dal backup cloud di Android e dal trasferimento tra dispositivi, quindi la cassaforte non viene caricata sul tuo account Google.\n\nModifiche: questa informativa può essere aggiornata; le modifiche rilevanti saranno indicate nelle note di versione.\n\nDomande: scrivi a yrkkh.cclub@gmail.com. + Usando PassKey accetti quanto segue.\n\n1. PassKey è software gratuito e open source sviluppato da Aditya Bhardwaj.\n\n2. La tua cassaforte è archiviata solo sul tuo dispositivo. Non esiste copia lato server e quindi non c\'è modo di recuperare i dati se perdi il dispositivo, disinstalli l\'app o dimentichi una password di backup.\n\n3. Conservare i backup è una tua responsabilità. Esportali regolarmente e custodisci la password del backup in un luogo sicuro e separato.\n\n4. L\'app si appoggia alla sicurezza biometrica o al blocco schermo del dispositivo. Se tale blocco è debole, rimosso o compromesso, la protezione offerta da PassKey si riduce di conseguenza.\n\n5. PassKey include i servizi Google Firebase e Google Play descritti nell\'Informativa sulla privacy.\n\n6. L\'applicazione è fornita \"così com\'è\", senza garanzie di alcun tipo, esplicite o implicite, comprese quelle di commerciabilità e idoneità a uno scopo particolare.\n\n7. Nei limiti consentiti dalla legge, lo sviluppatore non è responsabile per danni derivanti dall\'uso dell\'applicazione, inclusa la perdita di dati.\n\n8. L\'applicazione può essere modificata o dismessa in qualsiasi momento. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9ee1dd5..9808cb5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -133,4 +133,7 @@ 鍵 ログイン + PassKey は、ログイン情報・パスワード・その他の非公開メモを、お使いの端末上の暗号化データベースに保存します。\n\n保管庫が端末外に出ることはありません。アカウントも同期も PassKey のサーバーも存在せず、アプリ自身はネットワーク通信を行わないため、登録内容がどこかへ送信されることはありません。\n\nデータベースは SQLCipher で暗号化され、端末の生体認証または画面ロックで解錠します。アプリを離れると保管庫は自動的に再ロックされ、スクリーンショットは禁止され、コピーしたパスワードは機密として扱われ、まもなくクリップボードから消去されます。\n\n項目はカテゴリ分けやドラッグによる並べ替えができ、強力なパスワードの生成や、脆弱・使い回しパスワードの点検も行えます。バックアップは、ご自身で決めたパスワードで暗号化した単一のファイルとして書き出され、どの端末でも復元できます。\n\nPassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアで、ソースコードはすべて GitHub で公開されています。 + 保管庫は端末内に留まります。\n\n収集しないもの: PassKey は、保存されたパスワード・ユーザー名・メモなどの項目を一切収集・送信・保管しません。それらは端末上の暗号化データベースにのみ存在します。当方が内容を読むことも、復元することもできません。\n\nアプリが送信するもの: PassKey には Google Firebase Crashlytics、Analytics、Performance Monitoring、および Google Play のアプリ内更新が含まれます。これらはクラッシュ診断、利用イベント、パフォーマンス情報、広告 ID を含む端末およびインストール識別子を Google に送信します。保管庫の中身にアクセスすることはありません。これらのサービスのため、アプリはインターネット権限を必要とし、そのデータの取り扱いは Google のプライバシーポリシーに従います。\n\nバックアップ: 書き出したファイルはご自身で決めたパスワードで暗号化され、システムで指定した場所に保存されます。以後そのファイルの管理はご自身の責任となり、ファイルとパスワードの双方を入手した者は内容を読むことができます。当方は控えを保持しません。\n\n端末バックアップ: PassKey は Android のクラウドバックアップおよび端末間転送の対象外です。保管庫が Google アカウントへアップロードされることはありません。\n\n変更: 本ポリシーは更新される場合があります。重要な変更はリリースノートに記載します。\n\nお問い合わせ: yrkkh.cclub@gmail.com までご連絡ください。 + PassKey を利用することで、以下に同意したものとみなされます。\n\n1. PassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアです。\n\n2. 保管庫は端末上にのみ保存されます。サーバー側の控えは存在しないため、端末の紛失、アプリの削除、バックアップパスワードの失念があった場合にデータを復元する手段はありません。\n\n3. バックアップの保持は利用者の責任です。定期的に書き出し、バックアップパスワードは安全な別の場所に保管してください。\n\n4. 本アプリは端末の生体認証または画面ロックに依存します。そのロックが脆弱、解除済み、または侵害されている場合、PassKey による保護もその分低下します。\n\n5. PassKey にはプライバシーポリシーに記載の Google Firebase および Google Play のサービスが含まれます。\n\n6. 本アプリケーションは「現状有姿」で提供され、商品性および特定目的への適合性を含め、明示黙示を問わずいかなる保証も行いません。\n\n7. 法令が許す範囲において、開発者はデータの喪失を含む本アプリケーションの利用に起因する損害について責任を負いません。\n\n8. 本アプリケーションは予告なく変更または提供終了となる場合があります。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9931faf..522cd50 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -134,4 +134,7 @@ 키 로그인 + PassKey는 로그인 정보, 비밀번호, 그 밖의 비공개 메모를 기기 안의 암호화된 데이터베이스에 저장합니다.\n\n보관함은 기기를 벗어나지 않습니다. 계정도, 동기화도, PassKey 서버도 없습니다. 앱 자체는 네트워크 요청을 하지 않으며 저장한 항목이 어디로도 전송되지 않습니다.\n\n데이터베이스는 SQLCipher로 암호화되며 기기의 생체 인증이나 화면 잠금으로 해제합니다. 앱을 벗어나면 보관함이 자동으로 다시 잠기고, 스크린샷은 차단되며, 복사한 비밀번호는 민감 정보로 표시된 뒤 잠시 후 클립보드에서 지워집니다.\n\n항목을 카테고리로 정리하고 끌어서 순서를 바꿀 수 있으며, 강력한 비밀번호를 생성하고 취약하거나 재사용된 비밀번호를 점검할 수 있습니다. 백업은 직접 정한 비밀번호로 암호화된 단일 파일로 내보내지며 어떤 기기에서도 복원할 수 있습니다.\n\nPassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어이며 전체 소스 코드는 GitHub에서 공개되어 있습니다. + 보관함은 기기에 남아 있습니다.\n\n수집하지 않는 것: PassKey는 저장한 비밀번호, 사용자 이름, 메모 등 어떤 항목도 수집하거나 전송하거나 보관하지 않습니다. 해당 정보는 기기의 암호화된 데이터베이스에만 존재합니다. 저희는 이를 읽을 수도, 대신 복구해 드릴 수도 없습니다.\n\n앱이 전송하는 것: PassKey에는 Google Firebase Crashlytics, Analytics, Performance Monitoring과 Google Play 인앱 업데이트가 포함됩니다. 이들은 비정상 종료 진단, 사용 이벤트, 성능 기록, 광고 ID를 포함한 기기 및 설치 식별자를 Google로 전송합니다. 보관함 내용에는 접근하지 않습니다. 이러한 서비스 때문에 앱은 인터넷 권한을 필요로 하며, 해당 데이터 처리는 Google의 개인정보처리방침을 따릅니다.\n\n백업: 백업을 내보내면 직접 정한 비밀번호로 파일이 암호화되어 지정한 위치에 저장됩니다. 이후 그 파일은 사용자의 책임이며, 파일과 비밀번호를 모두 가진 사람은 내용을 읽을 수 있습니다. 저희는 사본을 보관하지 않습니다.\n\n기기 백업: PassKey는 Android 클라우드 백업과 기기 간 전송에서 제외되어 보관함이 Google 계정에 업로드되지 않습니다.\n\n변경: 본 방침은 변경될 수 있으며, 중요한 변경은 앱 출시 노트에 기재됩니다.\n\n문의: yrkkh.cclub@gmail.com 로 연락해 주세요. + PassKey를 사용함으로써 다음에 동의하게 됩니다.\n\n1. PassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어입니다.\n\n2. 보관함은 기기에만 저장됩니다. 서버 사본이 없으므로 기기를 분실하거나 앱을 삭제하거나 백업 비밀번호를 잊은 경우 데이터를 복구할 방법이 없습니다.\n\n3. 백업 보관은 사용자의 책임입니다. 정기적으로 내보내고 백업 비밀번호는 안전한 별도 장소에 보관하세요.\n\n4. 앱은 기기의 생체 인증 또는 화면 잠금 보안에 의존합니다. 해당 잠금이 취약하거나 해제되거나 침해되면 PassKey가 제공하는 보호도 그만큼 약해집니다.\n\n5. PassKey에는 개인정보처리방침에 기술된 Google Firebase 및 Google Play 서비스가 포함됩니다.\n\n6. 본 애플리케이션은 상품성 및 특정 목적 적합성을 포함하여 명시적이든 묵시적이든 어떠한 보증도 없이 \"있는 그대로\" 제공됩니다.\n\n7. 법이 허용하는 범위에서 개발자는 데이터 손실을 포함해 애플리케이션 사용으로 발생한 손해에 대해 책임지지 않습니다.\n\n8. 애플리케이션은 언제든 변경되거나 중단될 수 있습니다. \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 5366c0d..1ebde8c 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -135,4 +135,7 @@ किल्ली लॉगिन + PassKey तुमचे लॉगिन, पासवर्ड आणि इतर खाजगी नोंदी तुमच्या उपकरणावरील एन्क्रिप्टेड डेटाबेसमध्ये साठवते.\n\nतुमची व्हॉल्ट कधीही फोनबाहेर जात नाही. खाते नाही, सिंक नाही, PassKey चा सर्व्हरही नाही: ॲप स्वतःहून कोणतीही नेटवर्क विनंती करत नाही आणि तुमच्या नोंदी कुठेही पाठवल्या जात नाहीत.\n\nडेटाबेस SQLCipher ने एन्क्रिप्ट केलेला असून तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन लॉकने उघडतो. ॲप सोडताच व्हॉल्ट आपोआप पुन्हा लॉक होते, स्क्रीनशॉट रोखलेले आहेत, आणि कॉपी केलेले पासवर्ड संवेदनशील म्हणून चिन्हांकित होऊन थोड्याच वेळात क्लिपबोर्डमधून हटवले जातात.\n\nनोंदी श्रेणींमध्ये मांडता येतात, ओढून क्रम बदलता येतो, मजबूत पासवर्ड तयार करता येतात आणि कमकुवत किंवा पुन्हा वापरलेले पासवर्ड तपासता येतात. बॅकअप एकाच फाइलमध्ये निर्यात होतो, तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट केलेला, आणि कोणत्याही उपकरणावर पुनर्संचयित करता येतो.\n\nPassKey विनामूल्य आणि मुक्तस्रोत आहे, Aditya Bhardwaj यांनी विकसित केलेले. संपूर्ण स्रोत कोड GitHub वर उपलब्ध आहे. + तुमची व्हॉल्ट तुमच्या उपकरणावरच राहते.\n\nआम्ही कधीही काय गोळा करत नाही: PassKey तुम्ही जतन केलेले पासवर्ड, वापरकर्तानावे, नोंदी किंवा इतर कोणतीही माहिती गोळा, प्रसारित किंवा साठवत नाही. ती फक्त तुमच्या फोनवरील एन्क्रिप्टेड डेटाबेसमध्ये असते. आम्ही ती वाचू शकत नाही आणि तुमच्यासाठी परत मिळवूही शकत नाही.\n\nॲप काय पाठवते: PassKey मध्ये Google Firebase Crashlytics, Analytics आणि Performance Monitoring, तसेच Google Play इन-ॲप अपडेट समाविष्ट आहेत. हे क्रॅश निदान, वापर घटना, कार्यक्षमता माहिती आणि जाहिरात आयडीसह उपकरण व स्थापना ओळखचिन्हे Google ला पाठवतात. त्यांना तुमच्या व्हॉल्टच्या मजकुरापर्यंत कधीही प्रवेश नसतो. या सेवांमुळे ॲपला इंटरनेट परवानगी लागते आणि त्यांची माहिती हाताळणी Google च्या गोपनीयता धोरणानुसार होते.\n\nबॅकअप: निर्यात करताना फाइल तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट होते आणि तुम्ही सिस्टमला सांगाल तिथे साठवली जाते. त्यानंतर ती फाइल तुमची जबाबदारी आहे; ज्याच्याकडे फाइल आणि पासवर्ड दोन्ही असेल तो ती वाचू शकतो. आम्ही प्रत ठेवत नाही.\n\nउपकरण बॅकअप: PassKey ला Android क्लाउड बॅकअप आणि उपकरण-ते-उपकरण हस्तांतरणातून वगळले आहे, त्यामुळे तुमची व्हॉल्ट तुमच्या Google खात्यावर अपलोड होत नाही.\n\nबदल: हे धोरण अद्ययावत होऊ शकते; महत्त्वाचे बदल ॲपच्या रिलीज नोट्समध्ये नमूद केले जातील.\n\nप्रश्न: संपर्क yrkkh.cclub@gmail.com. + PassKey वापरून तुम्ही पुढील बाबींना सहमती देता.\n\n1. PassKey हे Aditya Bhardwaj यांनी विकसित केलेले विनामूल्य, मुक्तस्रोत सॉफ्टवेअर आहे.\n\n2. तुमची व्हॉल्ट फक्त तुमच्या उपकरणावर साठवली जाते. सर्व्हरवर प्रत नाही, त्यामुळे उपकरण हरवल्यास, ॲप काढून टाकल्यास किंवा बॅकअप पासवर्ड विसरल्यास डेटा परत मिळवण्याचा मार्ग नाही.\n\n3. बॅकअप ठेवणे ही तुमची जबाबदारी आहे. ते नियमितपणे निर्यात करा आणि बॅकअप पासवर्ड सुरक्षित, वेगळ्या ठिकाणी ठेवा.\n\n4. ॲप तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन-लॉक सुरक्षेवर अवलंबून आहे. ते लॉक कमकुवत, काढलेले किंवा तडजोड झालेले असल्यास PassKey देत असलेले संरक्षण त्या प्रमाणात कमी होते.\n\n5. PassKey मध्ये गोपनीयता धोरणात वर्णन केलेल्या Google Firebase आणि Google Play सेवा समाविष्ट आहेत.\n\n6. हे ॲप्लिकेशन \"जसे आहे तसे\" पुरवले जाते, कोणत्याही स्पष्ट किंवा गर्भित हमीशिवाय, ज्यात व्यापारयोग्यता आणि विशिष्ट हेतूसाठी उपयुक्तता समाविष्ट आहे.\n\n7. कायद्याने परवानगी दिलेल्या मर्यादेत, ॲप्लिकेशनच्या वापरामुळे होणाऱ्या कोणत्याही नुकसानीस, डेटा हानीसह, विकसक जबाबदार नाही.\n\n8. ॲप्लिकेशन कधीही सुधारले किंवा बंद केले जाऊ शकते. \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1fcca50..2df2e3a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -136,4 +136,7 @@ chave acesso + O PassKey guarda seus logins, senhas e outras notas privadas em um banco de dados criptografado no seu dispositivo.\n\nSeu cofre nunca sai do celular. Não há conta, sincronização nem servidor do PassKey: o aplicativo não faz requisições de rede próprias e suas entradas nunca são transmitidas para lugar algum.\n\nO banco de dados é criptografado com SQLCipher e desbloqueado com a biometria ou o bloqueio de tela do aparelho. O cofre volta a bloquear automaticamente quando você sai do app, capturas de tela são bloqueadas e senhas copiadas são marcadas como sensíveis e apagadas da área de transferência logo depois.\n\nVocê pode organizar entradas em categorias, reordená-las arrastando, gerar senhas fortes e verificar o cofre em busca de senhas fracas ou repetidas. Os backups são exportados como um único arquivo criptografado com uma senha escolhida por você e podem ser restaurados em qualquer dispositivo.\n\nO PassKey é gratuito e de código aberto, desenvolvido por Aditya Bhardwaj. O código-fonte completo está disponível no GitHub. + Seu cofre permanece no seu dispositivo.\n\nO que nunca coletamos: o PassKey não coleta, transmite nem armazena as senhas, nomes de usuário, notas ou quaisquer outras entradas que você salva. Elas existem apenas em um banco de dados criptografado no seu celular. Não podemos lê-las nem recuperá-las para você.\n\nO que o aplicativo envia: o PassKey inclui Google Firebase Crashlytics, Analytics e Performance Monitoring, além das atualizações no app do Google Play. Esses serviços enviam ao Google diagnósticos de falhas, eventos de uso, rastros de desempenho e identificadores de dispositivo e instalação, incluindo um identificador de publicidade. Eles nunca têm acesso ao conteúdo do seu cofre. Por causa desses serviços o app exige a permissão de internet, e o tratamento desses dados segue a política de privacidade do Google.\n\nBackups: ao exportar um backup, o arquivo é criptografado com uma senha escolhida por você e gravado onde você indicar ao sistema. Esse arquivo passa a ser responsabilidade sua; quem obtiver o arquivo e a senha poderá lê-lo. Não guardamos cópia.\n\nBackup do dispositivo: o PassKey está excluído do backup na nuvem do Android e da transferência entre dispositivos, então seu cofre não é enviado para sua conta Google.\n\nAlterações: esta política pode ser atualizada; mudanças relevantes serão indicadas nas notas de versão.\n\nDúvidas: escreva para yrkkh.cclub@gmail.com. + Ao usar o PassKey você concorda com o seguinte.\n\n1. O PassKey é software gratuito e de código aberto desenvolvido por Aditya Bhardwaj.\n\n2. Seu cofre é armazenado apenas no seu dispositivo. Não existe cópia em servidor e, portanto, não há como recuperar seus dados se você perder o aparelho, desinstalar o app ou esquecer uma senha de backup.\n\n3. Manter backups é responsabilidade sua. Exporte-os regularmente e guarde a senha do backup em local seguro e separado.\n\n4. O app depende da segurança biométrica ou do bloqueio de tela do seu aparelho. Se esse bloqueio for fraco, removido ou comprometido, a proteção oferecida pelo PassKey diminui na mesma medida.\n\n5. O PassKey inclui serviços do Google Firebase e do Google Play conforme descrito na Política de Privacidade.\n\n6. O aplicativo é fornecido \"no estado em que se encontra\", sem garantia de qualquer tipo, expressa ou implícita, incluindo comercialização e adequação a uma finalidade específica.\n\n7. Na medida permitida por lei, o desenvolvedor não se responsabiliza por danos decorrentes do uso do aplicativo, incluindo perda de dados.\n\n8. O aplicativo pode ser modificado ou descontinuado a qualquer momento. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2672789..7c68018 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -136,4 +136,7 @@ ключ вход + PassKey хранит ваши учётные данные, пароли и другие личные заметки в зашифрованной базе данных на вашем устройстве.\n\nВаше хранилище никогда не покидает телефон. Нет ни учётной записи, ни синхронизации, ни сервера PassKey: приложение не выполняет собственных сетевых запросов, и ваши записи никуда не передаются.\n\nБаза данных зашифрована с помощью SQLCipher и разблокируется биометрией или блокировкой экрана устройства. Хранилище автоматически блокируется, когда вы выходите из приложения, снимки экрана запрещены, а скопированные пароли помечаются как конфиденциальные и вскоре удаляются из буфера обмена.\n\nЗаписи можно распределять по категориям, менять их порядок перетаскиванием, генерировать надёжные пароли и проверять хранилище на слабые или повторяющиеся пароли. Резервные копии экспортируются одним файлом, зашифрованным выбранным вами паролем, и восстанавливаются на любом устройстве.\n\nPassKey — бесплатное приложение с открытым исходным кодом, разработанное Aditya Bhardwaj. Полный исходный код доступен на GitHub. + Ваше хранилище остаётся на вашем устройстве.\n\nЧто мы никогда не собираем: PassKey не собирает, не передаёт и не хранит пароли, имена пользователей, заметки и любые другие сохранённые вами записи. Они существуют только в зашифрованной базе данных на вашем телефоне. Мы не можем ни прочитать их, ни восстановить для вас.\n\nЧто отправляет приложение: PassKey включает Google Firebase Crashlytics, Analytics и Performance Monitoring, а также встроенные обновления Google Play. Эти сервисы отправляют в Google диагностику сбоев, события использования, данные о производительности, а также идентификаторы устройства и установки, включая рекламный идентификатор. Доступа к содержимому хранилища у них нет никогда. Из-за этих сервисов приложению требуется разрешение на доступ в интернет, а обработка этих данных регулируется политикой конфиденциальности Google.\n\nРезервные копии: при экспорте файл шифруется выбранным вами паролем и сохраняется туда, куда вы укажете системе. Далее этот файл — ваша ответственность; любой, у кого окажутся и файл, и пароль, сможет его прочитать. Мы копий не храним.\n\nРезервное копирование устройства: PassKey исключено из облачного резервного копирования Android и переноса между устройствами, поэтому хранилище не выгружается в ваш аккаунт Google.\n\nИзменения: политика может обновляться; существенные изменения отмечаются в примечаниях к выпуску.\n\nВопросы: пишите на yrkkh.cclub@gmail.com. + Используя PassKey, вы соглашаетесь со следующим.\n\n1. PassKey — бесплатное программное обеспечение с открытым исходным кодом, разработанное Aditya Bhardwaj.\n\n2. Ваше хранилище находится только на вашем устройстве. Серверной копии нет, а значит нет и способа восстановить данные, если вы потеряете устройство, удалите приложение или забудете пароль резервной копии.\n\n3. Резервные копии — ваша ответственность. Делайте их регулярно и храните пароль от копии в надёжном и отдельном месте.\n\n4. Приложение опирается на биометрию или блокировку экрана вашего устройства. Если эта защита слабая, отключена или скомпрометирована, защита со стороны PassKey снижается соответственно.\n\n5. PassKey включает сервисы Google Firebase и Google Play, как описано в политике конфиденциальности.\n\n6. Приложение предоставляется «как есть», без каких-либо гарантий, явных или подразумеваемых, включая пригодность для продажи и соответствие конкретной цели.\n\n7. В пределах, допускаемых законом, разработчик не несёт ответственности за ущерб, возникший в результате использования приложения, включая потерю данных.\n\n8. Приложение может быть изменено или прекращено в любое время. diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index c9d0355..29657ee 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -134,4 +134,7 @@ சாவி உள்நுழைவு + PassKey உங்கள் உள்நுழைவுத் தகவல்கள், கடவுச்சொற்கள் மற்றும் பிற தனிப்பட்ட குறிப்புகளை உங்கள் சாதனத்தில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் சேமிக்கிறது.\n\nஉங்கள் பெட்டகம் ஒருபோதும் தொலைபேசியை விட்டு வெளியே செல்வதில்லை. கணக்கு இல்லை, ஒத்திசைவு இல்லை, PassKey சேவையகமும் இல்லை: ஆப்ஸ் தானாக எந்த நெட்வொர்க் கோரிக்கையும் செய்வதில்லை, உங்கள் பதிவுகள் எங்கும் அனுப்பப்படுவதில்லை.\n\nதரவுத்தளம் SQLCipher மூலம் மறையாக்கப்பட்டு, உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டால் திறக்கப்படுகிறது. ஆப்ஸை விட்டு வெளியேறியதும் பெட்டகம் தானாகவே மீண்டும் பூட்டப்படும், திரைப்பிடிப்புகள் தடுக்கப்படுகின்றன, நகலெடுத்த கடவுச்சொற்கள் முக்கியமானவை எனக் குறிக்கப்பட்டு சிறிது நேரத்தில் இடைநிலைப் பலகையிலிருந்து அழிக்கப்படுகின்றன.\n\nபதிவுகளை வகைகளாக ஒழுங்குபடுத்தலாம், இழுத்து வரிசை மாற்றலாம், வலுவான கடவுச்சொற்களை உருவாக்கலாம், பலவீனமான அல்லது மீண்டும் பயன்படுத்திய கடவுச்சொற்களைச் சரிபார்க்கலாம். காப்புப்பிரதிகள் நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்ட ஒற்றைக் கோப்பாக ஏற்றுமதி செய்யப்பட்டு எந்தச் சாதனத்திலும் மீட்டெடுக்கப்படலாம்.\n\nPassKey இலவசமானது, திறந்த மூலமானது, Aditya Bhardwaj உருவாக்கியது. முழு மூலக் குறியீடும் GitHub இல் கிடைக்கிறது. + உங்கள் பெட்டகம் உங்கள் சாதனத்திலேயே இருக்கும்.\n\nநாங்கள் ஒருபோதும் சேகரிக்காதவை: நீங்கள் சேமிக்கும் கடவுச்சொற்கள், பயனர்பெயர்கள், குறிப்புகள் அல்லது பிற எந்தப் பதிவையும் PassKey சேகரிக்கவோ, அனுப்பவோ, சேமிக்கவோ இல்லை. அவை உங்கள் தொலைபேசியில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் மட்டுமே இருக்கும். அவற்றை எங்களால் படிக்கவும் முடியாது, உங்களுக்காக மீட்டெடுக்கவும் முடியாது.\n\nஆப்ஸ் அனுப்புபவை: PassKey இல் Google Firebase Crashlytics, Analytics, Performance Monitoring மற்றும் Google Play ஆப்ஸ்-உள் புதுப்பிப்புகள் உள்ளன. இவை செயலிழப்பு நோயறிதல், பயன்பாட்டு நிகழ்வுகள், செயல்திறன் தரவு, விளம்பர ID உள்ளிட்ட சாதன மற்றும் நிறுவல் அடையாளங்காட்டிகளை Google க்கு அனுப்புகின்றன. உங்கள் பெட்டக உள்ளடக்கத்திற்கு இவற்றுக்கு ஒருபோதும் அணுகல் இல்லை. இச்சேவைகளால் ஆப்ஸுக்கு இணைய அனுமதி தேவை, அவற்றின் தரவு கையாளுதல் Google இன் தனியுரிமைக் கொள்கையின் கீழ் வருகிறது.\n\nகாப்புப்பிரதிகள்: ஏற்றுமதி செய்யும்போது கோப்பு நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்டு, நீங்கள் கணினியிடம் குறிப்பிட்ட இடத்தில் சேமிக்கப்படும். அதன் பிறகு அக்கோப்பு உங்கள் பொறுப்பு; கோப்பையும் கடவுச்சொல்லையும் பெறுபவர் அதைப் படிக்க முடியும். நாங்கள் நகல் வைத்திருப்பதில்லை.\n\nசாதனக் காப்புப்பிரதி: PassKey Android கிளவுட் காப்புப்பிரதி மற்றும் சாதனங்களுக்கிடையேயான இடமாற்றத்திலிருந்து விலக்கப்பட்டுள்ளது, எனவே உங்கள் பெட்டகம் உங்கள் Google கணக்கிற்குப் பதிவேற்றப்படுவதில்லை.\n\nமாற்றங்கள்: இக்கொள்கை புதுப்பிக்கப்படலாம்; முக்கிய மாற்றங்கள் ஆப்ஸின் வெளியீட்டுக் குறிப்புகளில் தெரிவிக்கப்படும்.\n\nகேள்விகள்: தொடர்பு yrkkh.cclub@gmail.com. + PassKey ஐப் பயன்படுத்துவதன் மூலம் பின்வருவனவற்றை ஏற்கிறீர்கள்.\n\n1. PassKey என்பது Aditya Bhardwaj உருவாக்கிய இலவச, திறந்த மூல மென்பொருள்.\n\n2. உங்கள் பெட்டகம் உங்கள் சாதனத்தில் மட்டுமே சேமிக்கப்படுகிறது. சேவையகத்தில் நகல் இல்லை, எனவே சாதனத்தை இழந்தால், ஆப்ஸை நீக்கினால் அல்லது காப்புப்பிரதி கடவுச்சொல்லை மறந்தால் தரவை மீட்க வழி இல்லை.\n\n3. காப்புப்பிரதிகளை வைத்திருப்பது உங்கள் பொறுப்பு. அவற்றை வழக்கமாக ஏற்றுமதி செய்து, காப்புப்பிரதி கடவுச்சொல்லைப் பாதுகாப்பான, தனித்த இடத்தில் வைக்கவும்.\n\n4. ஆப்ஸ் உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டுப் பாதுகாப்பை நம்பியுள்ளது. அப்பூட்டு பலவீனமாக, அகற்றப்பட்டதாக அல்லது சமரசம் செய்யப்பட்டதாக இருந்தால், PassKey அளிக்கும் பாதுகாப்பும் அதற்கேற்ப குறையும்.\n\n5. PassKey இல் தனியுரிமைக் கொள்கையில் விவரிக்கப்பட்ட Google Firebase மற்றும் Google Play சேவைகள் உள்ளன.\n\n6. இப்பயன்பாடு \"உள்ளபடி\" வழங்கப்படுகிறது, வணிகத்தகுதி மற்றும் குறிப்பிட்ட நோக்கத்திற்கான உகந்ததன்மை உட்பட, வெளிப்படையான அல்லது மறைமுகமான எந்த உத்தரவாதமும் இன்றி.\n\n7. சட்டம் அனுமதிக்கும் அளவில், பயன்பாட்டின் பயன்பாட்டால் ஏற்படும் எந்த சேதத்திற்கும், தரவு இழப்பு உட்பட, உருவாக்குநர் பொறுப்பல்ல.\n\n8. பயன்பாடு எந்த நேரத்திலும் மாற்றப்படலாம் அல்லது நிறுத்தப்படலாம். diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index c1ca3fa..6a8862b 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -134,5 +134,8 @@ కీ లాగిన్ + PassKey మీ లాగిన్‌లు, పాస్‌వర్డ్‌లు మరియు ఇతర వ్యక్తిగత గమనికలను మీ పరికరంలోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో నిల్వ చేస్తుంది.\n\nమీ వాల్ట్ ఎప్పుడూ మీ ఫోన్‌ను వదిలి వెళ్లదు. ఖాతా లేదు, సింక్ లేదు, PassKey సర్వర్ కూడా లేదు: యాప్ తనంతట తానుగా ఎలాంటి నెట్‌వర్క్ అభ్యర్థనలూ చేయదు, మీ ఎంట్రీలు ఎక్కడికీ పంపబడవు.\n\nడేటాబేస్ SQLCipher తో ఎన్‌క్రిప్ట్ చేయబడి, మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్ లాక్‌తో తెరుచుకుంటుంది. యాప్ నుండి బయటకు వెళ్లగానే వాల్ట్ స్వయంచాలకంగా మళ్లీ లాక్ అవుతుంది, స్క్రీన్‌షాట్‌లు నిరోధించబడ్డాయి, కాపీ చేసిన పాస్‌వర్డ్‌లు సున్నితమైనవిగా గుర్తించబడి కొద్దిసేపటిలో క్లిప్‌బోర్డ్ నుండి తొలగించబడతాయి.\n\nఎంట్రీలను వర్గాలుగా అమర్చవచ్చు, లాగి క్రమం మార్చవచ్చు, బలమైన పాస్‌వర్డ్‌లు సృష్టించవచ్చు, బలహీనమైన లేదా పునరావృత పాస్‌వర్డ్‌లను తనిఖీ చేయవచ్చు. బ్యాకప్‌లు మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ చేసిన ఒకే ఫైల్‌గా ఎగుమతి అవుతాయి, ఏ పరికరంలోనైనా పునరుద్ధరించవచ్చు.\n\nPassKey ఉచితం, ఓపెన్ సోర్స్, Aditya Bhardwaj అభివృద్ధి చేశారు. పూర్తి సోర్స్ కోడ్ GitHub లో అందుబాటులో ఉంది. + మీ వాల్ట్ మీ పరికరంలోనే ఉంటుంది.\n\nమేము ఎప్పుడూ సేకరించనివి: మీరు సేవ్ చేసిన పాస్‌వర్డ్‌లు, వినియోగదారు పేర్లు, గమనికలు లేదా ఇతర ఏ ఎంట్రీలనూ PassKey సేకరించదు, పంపదు, నిల్వ చేయదు. అవి మీ ఫోన్‌లోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో మాత్రమే ఉంటాయి. మేము వాటిని చదవలేము, మీ కోసం తిరిగి పొందలేము.\n\nయాప్ పంపేవి: PassKey లో Google Firebase Crashlytics, Analytics, Performance Monitoring మరియు Google Play ఇన్-యాప్ అప్‌డేట్‌లు ఉన్నాయి. ఇవి క్రాష్ డయాగ్నస్టిక్స్, వినియోగ ఈవెంట్‌లు, పనితీరు డేటా, ప్రకటన IDతో సహా పరికర మరియు ఇన్‌స్టాలేషన్ గుర్తింపులను Google కు పంపుతాయి. మీ వాల్ట్ కంటెంట్‌కు వీటికి ఎప్పుడూ ప్రాప్యత ఉండదు. ఈ సేవల కారణంగా యాప్‌కు ఇంటర్నెట్ అనుమతి అవసరం, వాటి డేటా నిర్వహణ Google గోప్యతా విధానం ప్రకారం జరుగుతుంది.\n\nబ్యాకప్‌లు: ఎగుమతి చేసినప్పుడు ఫైల్ మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ అవుతుంది, మీరు సిస్టమ్‌కు చెప్పిన చోట నిల్వ అవుతుంది. ఆ తర్వాత ఆ ఫైల్ మీ బాధ్యత; ఫైల్ మరియు పాస్‌వర్డ్ రెండూ పొందిన ఎవరైనా దాన్ని చదవగలరు. మేము ఎలాంటి కాపీ ఉంచుకోము.\n\nపరికర బ్యాకప్: PassKey Android క్లౌడ్ బ్యాకప్ మరియు పరికరాల మధ్య బదిలీ నుండి మినహాయించబడింది, కాబట్టి మీ వాల్ట్ మీ Google ఖాతాకు అప్‌లోడ్ కాదు.\n\nమార్పులు: ఈ విధానం నవీకరించబడవచ్చు; ముఖ్యమైన మార్పులు యాప్ విడుదల గమనికలలో తెలియజేయబడతాయి.\n\nప్రశ్నలు: సంప్రదించండి yrkkh.cclub@gmail.com. + PassKey ను ఉపయోగించడం ద్వారా మీరు కింది వాటికి అంగీకరిస్తున్నారు.\n\n1. PassKey అనేది Aditya Bhardwaj అభివృద్ధి చేసిన ఉచిత, ఓపెన్-సోర్స్ సాఫ్ట్‌వేర్.\n\n2. మీ వాల్ట్ మీ పరికరంలో మాత్రమే నిల్వ చేయబడుతుంది. సర్వర్‌లో కాపీ లేదు, కాబట్టి పరికరం పోగొట్టుకున్నా, యాప్ అన్‌ఇన్‌స్టాల్ చేసినా, బ్యాకప్ పాస్‌వర్డ్ మరచిపోయినా డేటాను తిరిగి పొందే మార్గం లేదు.\n\n3. బ్యాకప్‌లు ఉంచుకోవడం మీ బాధ్యత. వాటిని క్రమం తప్పకుండా ఎగుమతి చేయండి, బ్యాకప్ పాస్‌వర్డ్‌ను సురక్షితమైన, వేరే చోట ఉంచండి.\n\n4. యాప్ మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్-లాక్ భద్రతపై ఆధారపడి ఉంటుంది. ఆ లాక్ బలహీనంగా, తొలగించబడి లేదా రాజీపడి ఉంటే, PassKey అందించే రక్షణ కూడా అదే మేరకు తగ్గుతుంది.\n\n5. PassKey లో గోప్యతా విధానంలో వివరించిన Google Firebase మరియు Google Play సేవలు ఉన్నాయి.\n\n6. ఈ అప్లికేషన్ \"ఉన్నది ఉన్నట్లుగా\" అందించబడుతుంది, వర్తకయోగ్యత మరియు నిర్దిష్ట ప్రయోజనానికి అనుకూలత సహా, ఎలాంటి స్పష్టమైన లేదా అంతర్గత హామీ లేకుండా.\n\n7. చట్టం అనుమతించిన మేరకు, అప్లికేషన్ వినియోగం వల్ల కలిగే ఏ నష్టానికీ, డేటా నష్టంతో సహా, డెవలపర్ బాధ్యత వహించరు.\n\n8. అప్లికేషన్ ఎప్పుడైనా మార్చబడవచ్చు లేదా నిలిపివేయబడవచ్చు. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c8e88d2..0a28f55 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -134,4 +134,7 @@ 秘密 登录 + PassKey 将你的登录信息、密码和其他私密备注保存在设备上的加密数据库中。\n\n你的密码库永远不会离开手机。没有账号、没有同步、也没有 PassKey 服务器:应用本身不发起任何网络请求,你的条目不会被传输到任何地方。\n\n数据库使用 SQLCipher 加密,通过设备的生物识别或屏幕锁解锁。离开应用后密码库会自动重新锁定,截图被禁用,复制的密码会被标记为敏感内容并在稍后从剪贴板清除。\n\n你可以按类别整理条目、拖动排序、生成高强度密码,并检查密码库中是否存在弱密码或重复使用的密码。备份会导出为单个文件,使用你自己设定的密码加密,可在任意设备上恢复。\n\nPassKey 由 Aditya Bhardwaj 开发,免费且开源,完整源代码可在 GitHub 上获取。 + 你的密码库保留在你的设备上。\n\n我们从不收集的内容:PassKey 不会收集、传输或存储你保存的密码、用户名、备注或任何其他条目。它们只存在于你手机上的加密数据库中。我们既无法读取,也无法替你恢复。\n\n应用确实会发送的内容:PassKey 集成了 Google Firebase Crashlytics、Analytics 和 Performance Monitoring,以及 Google Play 应用内更新。这些服务会向 Google 发送崩溃诊断、使用事件、性能数据,以及包含广告 ID 在内的设备和安装标识符。它们始终无法访问你的密码库内容。由于这些服务,应用需要网络权限,其数据处理受 Google 隐私政策约束。\n\n备份:导出备份时,文件会使用你设定的密码加密,并保存到你指定的位置。此后该文件由你自行保管;同时获得文件和密码的人即可读取其内容。我们不保留任何副本。\n\n设备备份:PassKey 已排除在 Android 云备份和设备间转移之外,因此你的密码库不会上传到你的 Google 账号。\n\n变更:本政策可能更新,重大变更会在应用的版本说明中注明。\n\n如有疑问,请联系 yrkkh.cclub@gmail.com。 + 使用 PassKey 即表示你同意以下条款。\n\n1. PassKey 是由 Aditya Bhardwaj 开发的免费开源软件。\n\n2. 你的密码库仅存储在你的设备上。不存在服务器端副本,因此如果你丢失设备、卸载应用或忘记备份密码,将无法恢复数据。\n\n3. 保留备份是你的责任。请定期导出,并将备份密码保存在安全且独立的地方。\n\n4. 本应用依赖你设备的生物识别或屏幕锁安全性。若该锁定较弱、被移除或已被攻破,PassKey 提供的保护也会相应降低。\n\n5. PassKey 包含隐私政策中所述的 Google Firebase 和 Google Play 服务。\n\n6. 本应用按\"现状\"提供,不作任何明示或暗示的保证,包括适销性和特定用途适用性。\n\n7. 在法律允许的范围内,开发者不对因使用本应用而产生的任何损害负责,包括数据丢失。\n\n8. 本应用可能随时被修改或停止提供。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c283603..04b6399 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,16 +24,45 @@ Don’t compromise your passwords by typing them in public, let PassKey autofill those and keep your credentials secure. - - Welcome to Passkey: Password Manager, the ultimate solution for managing all your passwords and private information securely!\n\nAre you tired of remembering multiple passwords or annoyed of forgetting them? Passkey: Password Manager helps you store all your logins, passwords, and other private information safe and secure in an encrypted database.\n\nPasskey: Password Manager is an open-source application developed by Aditya Bhardwaj. It is designed to provide a secure way to manage all your passwords without worrying about data leaks. With Passkey: Password Manager, you can store your passwords locally on your device, and the application does not require internet permissions, making it even more secure.\n\nPasskey: Password Manager features an encrypted database to store your passwords, and biometric authentication is used to access your information. The application supports dark mode, and screenshot blocking features to ensure that your information is not accessible by unauthorized persons.\n\nThe application uses Drag & Drop and Swipe to Delete features, providing a user-friendly interface to manage all your passwords easily. The application also has a Separation of Content feature, which allows you to categorize your passwords into different sections for easy management.\n\nPasskey: Password Manager uses LiveData and MVVM architecture to ensure that the application is scalable, maintainable, and easy to use. SQL Cipher is used for Room DB Backup Encryption and Decryption, and Dagger-Hilt is used for dependency injection.\n\nIn conclusion, Passkey: Password Manager is a must-have application for everyone who wants to manage their passwords securely. With its features and user-friendly interface, you can keep all your passwords and private information safe and secure. Download Passkey: Password Manager now, and never worry about forgetting your passwords again! - At Passkey: Password Manager, we are committed to protecting your privacy. This privacy policy explains how we collect, use, and share your personal information when you use our application.\n\nInformation we collect: We do not collect any of your passwords or private information that you store in the Passkey: Password Manager application. Your information is stored locally on your phone and encrypted for added security.\n\nHow we use your information: We use your information to store your passwords and private information securely in an encrypted database. We do not share your information with any third-party.\n\nHow we protect your information: We use encrypted database storage to protect your information, and biometric authentication is used to access your information.\n\nHow we share your information: We do not share your information with any third-party.\n\nChanges to our privacy policy: We reserve the right to modify this privacy policy at any time without prior notice.\n\nIf you have any questions or concerns about our terms and conditions or privacy policy, please contact us at yrkkh.cclub@gmail.com\n\nWe hope you enjoy using Passkey: Password Manager to store your passwords and private information securely. - Welcome to Passkey: Password Manager! By downloading and using this application, you agree to comply with and be bound by the following terms and conditions:\n\n1. Passkey: Password Manager is an application developed by Aditya Bhardwaj, and the intellectual property rights are owned by Aditya Bhardwaj.\n\n2. The Passkey: Password Manager application is designed to store your passwords and private information securely in an encrypted database.\n\n3. The Passkey: Password Manager application does not store your credentials on servers, so your passwords and private information are in your hands.\n\n4. The Passkey: Password Manager application does not require any internet permission, and it is an open-source application.\n\n5. The Passkey: Password Manager application uses encrypted database storage to store your information, and biometric authentication is used to access your information.\n\n6. The Passkey: Password Manager application is provided "as is" without any warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability and fitness for a particular purpose.\n\n7. Aditya Bhardwaj is not liable for any damages arising from the use of the Passkey: Password Manager application, including but not limited to direct, indirect, incidental, or consequential damages.\n\n8. By downloading and using the Passkey: Password Manager application, you agree to indemnify and hold harmless Aditya Bhardwaj, its officers, employees, agents, and affiliates from any and all claims, damages, losses, liabilities, and expenses arising from your use of the application.\n\n9. Aditya Bhardwaj reserves the right to modify, suspend, or discontinue the Passkey: Password Manager application at any time without prior notice. + PassKey stores your logins, passwords and other private notes in an encrypted database on your device. + +Your vault never leaves your phone. There is no account, no sync and no PassKey server: the app makes no network requests of its own, and your entries are never transmitted anywhere. + +The database is encrypted with SQLCipher and unlocked with your device biometrics or screen lock. The vault re-locks automatically when you leave the app, screenshots are blocked, and copied passwords are marked sensitive and cleared from the clipboard shortly after. + +You can organise entries into categories, reorder them by dragging, generate strong passwords, and check your vault for weak or reused ones. Backups are exported as a single file encrypted with a password you choose, and can be restored on any device. + +PassKey is free and open source, developed by Aditya Bhardwaj. The full source is available on GitHub. + Your vault stays on your device. + +What we never collect: PassKey does not collect, transmit or store the passwords, usernames, notes or any other entries you save. They live only in an encrypted database on your phone. We cannot read them, and we cannot recover them for you. + +What the app does send: PassKey includes Google Firebase Crashlytics, Analytics and Performance Monitoring, and Google Play in-app updates. These send crash diagnostics, app usage events, performance traces, and device and installation identifiers including an advertising ID to Google. They never have access to your vault contents. Because of these services the app requires the internet permission, and their data handling is governed by Google\'s privacy policy. + +Backups: when you export a backup, the file is encrypted with a password you choose and written wherever you tell the system to put it. That file is then yours to manage; anyone who obtains both the file and its password can read it. We keep no copy. + +Device backup: PassKey is excluded from Android cloud backup and device-to-device transfer, so your vault is not uploaded to your Google account. + +Changes: this policy may be updated; material changes will be noted in the app\'s release notes. + +Questions: contact yrkkh.cclub@gmail.com. + By using PassKey you agree to the following. + +1. PassKey is free, open-source software developed by Aditya Bhardwaj. + +2. Your vault is stored only on your device. There is no server-side copy, and therefore no way for anyone to recover your data if you lose your device, uninstall the app, or forget a backup password. + +3. Keeping backups is your responsibility. Export them regularly and store the backup password somewhere safe and separate. + +4. The app relies on your device\'s biometric or screen-lock security. If your device lock is weak, removed or compromised, the protection PassKey provides is reduced accordingly. + +5. PassKey includes Google Firebase and Google Play services as described in the Privacy Policy. + +6. The application is provided \"as is\", without warranty of any kind, express or implied, including merchantability and fitness for a particular purpose. + +7. To the extent permitted by law, the developer is not liable for any damages arising from use of the application, including loss of data. + +8. The application may be modified or discontinued at any time. From 9e2352d058b3e781faeb76023c1c9f85a4ccd1ac Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 09:59:15 +0530 Subject: [PATCH 10/25] Replace the build-time database key with a per-install key in three slots The vault was encrypted with BuildConfig.PASS_PHRASE: one constant, identical on every install, recoverable from the APK. The database was therefore protected against someone who found the file alone and against nobody who also had the app. Biometric unlock was decorative on top of that - the prompt carried no CryptoObject, allowed BIOMETRIC_WEAK (which can never back a Keystore key), and its only effect was setting a flag nothing read. The key is now 32 bytes from SecureRandom, generated once per install and never stored bare. It is wrapped three times, and any one slot opens the vault: BIO Keystore key, per-use BIOMETRIC_STRONG, unwrapped through a CryptoObject. Bound to the current enrolment, so changing fingerprints invalidates it - which is the desired property for the everyday path. CRED Keystore key, device credential, 30s validity. Deliberately NOT invalidated by biometric enrolment, so the app self-heals after a fingerprint change without ever creating an unauthenticated path. REC Recovery password, Argon2id. Mandatory, because it is the only slot that survives the user removing their device lock, which destroys every auth-bound Keystore key. Without it there is a reachable state with permanent, total data loss. KeySlotFile refuses to persist without it. Unlock falls through BIO to CRED to REC. KeyPermanentlyInvalidatedException is thrown by Cipher.init before any prompt is shown, so an invalidated enrolment is detected silently and the user never sees a broken prompt; a successful CRED unlock rebuilds the BIO slot behind their back. Two hard constraints shaped this. androidx.biometric 1.1.0 throws IllegalArgumentException below API 30 if DEVICE_CREDENTIAL is among the allowed authenticators of a CryptoObject prompt, and minSdk is 28 - hence two distinct prompts rather than one. And auth-bound Keystore keys cannot be generated at all without a lock screen, so each Keystore slot is attempted independently: a device with a PIN but no fingerprint still gets CRED, and a device with neither still gets a working vault protected by the recovery password alone. The re-key uses sqlcipher_export into a sidecar, not PRAGMA rekey. Rekey rewrites every page in place with no journal protection across the operation, so an interruption leaves an unopenable file; exporting keeps the original untouched until a rename, which makes it atomic by construction. Key slots are persisted as PENDING *before* the export - the reverse order has a window where the file is re-keyed but the key exists nowhere. Two bugs the instrumented tests caught, both of which would have shipped: - ATTACH DATABASE does not accept a bind parameter for the path; passing one made SQLite try to open a file literally named "?". - the source connection is opened OPEN_READWRITE without CREATE, so ATTACH could not create the target file. It is now created up front. VaultDatabaseProvider replaces the eager @Provides database, which forced an open at ViewModel construction - before any authentication. The repository resolves Room Flows through flatMapLatest over the current database, which is what lets locking close it: collectors detach and re-subscribe instead of throwing "attempt to re-open an already-closed object". Locking now actually closes the database and zeroes the key bytes. The key is passed in SQLCipher raw-key form so it skips a 256,000-round PBKDF2 on every open. 12 instrumented migration tests, including an explicit assertion that user_version survives - sqlcipher_export does not copy it, and losing it makes Room either re-run migrations against migrated tables or destructively recreate them. 6 more cover the Argon2 native binding, which also gains explicit ProGuard keeps: argon2kt ships no consumer rules and survives today only via the default native-methods rule, which is too indirect to rely on in an unlock path. Verified end to end on an API 36 emulator with no device lock - the hardest case, since only the recovery slot can exist: the gate provisions, writes a REC-only slot file, opens the vault, and the resulting database header is random bytes where a plaintext SQLite file would read "SQLite format 3". Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 3 + app/proguard-rules.pro | 12 + .../com/bhardwaj/passkey/KeyDerivationTest.kt | 82 +++++ .../bhardwaj/passkey/VaultMigrationTest.kt | 190 +++++++++++ .../data/local/VaultDatabaseProvider.kt | 87 +++++ .../bhardwaj/passkey/data/security/AesGcm.kt | 41 +++ .../passkey/data/security/AppLockObserver.kt | 9 +- .../data/security/BiometricAuthenticator.kt | 149 +++++++++ .../data/security/DatabaseKeyManager.kt | 305 ++++++++++++++++++ .../bhardwaj/passkey/data/security/KdfSpec.kt | 48 +++ .../passkey/data/security/KeyDerivation.kt | 83 +++++ .../passkey/data/security/KeySlotStore.kt | 57 ++++ .../passkey/data/security/KeySlots.kt | 93 ++++++ .../passkey/data/security/KeyStoreWrapper.kt | 152 +++++++++ .../passkey/data/security/VaultMigration.kt | 189 +++++++++++ .../java/com/bhardwaj/passkey/di/AppModule.kt | 41 +-- .../passkey/domain/events/SecurityEvents.kt | 7 - .../repository/PasskeyRepositoryImpl.kt | 64 ++-- .../domain/viewModels/SecurityViewModel.kt | 58 ---- .../domain/viewModels/VaultGateViewModel.kt | 165 ++++++++++ .../presentation/navigation/NavGraph.kt | 11 +- .../screens/security_screen/SecurityPage.kt | 266 --------------- .../security_screen/VaultGateScreen.kt | 196 +++++++++++ .../components/BackupPasswordDialog.kt | 23 +- app/src/main/res/values-ar/strings.xml | 15 + app/src/main/res/values-bn/strings.xml | 15 + app/src/main/res/values-de/strings.xml | 15 + app/src/main/res/values-es/strings.xml | 15 + app/src/main/res/values-fr/strings.xml | 15 + app/src/main/res/values-gu/strings.xml | 15 + app/src/main/res/values-hi/strings.xml | 15 + app/src/main/res/values-it/strings.xml | 15 + app/src/main/res/values-ja/strings.xml | 15 + app/src/main/res/values-ko/strings.xml | 15 + app/src/main/res/values-mr/strings.xml | 15 + app/src/main/res/values-pt/strings.xml | 15 + app/src/main/res/values-ru/strings.xml | 15 + app/src/main/res/values-ta/strings.xml | 15 + app/src/main/res/values-te/strings.xml | 15 + app/src/main/res/values-zh/strings.xml | 15 + app/src/main/res/values/strings.xml | 17 + gradle/libs.versions.toml | 2 + 42 files changed, 2195 insertions(+), 395 deletions(-) create mode 100644 app/src/androidTest/java/com/bhardwaj/passkey/KeyDerivationTest.kt create mode 100644 app/src/androidTest/java/com/bhardwaj/passkey/VaultMigrationTest.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/AesGcm.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/BiometricAuthenticator.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/KdfSpec.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/KeyDerivation.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/KeySlotStore.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/KeySlots.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/events/SecurityEvents.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SecurityViewModel.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4f6a17..4ef78ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -163,6 +163,9 @@ dependencies { // Backup payload serialization implementation(libs.kotlinx.serialization.json) + // Argon2id for the recovery-password key slot + implementation(libs.argon2kt) + // Reordering Lazy Columns implementation(libs.reorderable) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3ff03e3..cf31358 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -30,3 +30,15 @@ # The previous rules in this file referenced the legacy `net.sqlcipher.**` package, which this # app has not used since migrating to sqlcipher-android. They matched nothing and were removed # so they cannot be mistaken for load-bearing configuration. + +# Argon2 (recovery-password key slot). +# The argon2kt AAR ships no consumer rules of its own. Argon2Jni survives today only because of +# the default "-keepclasseswithmembernames class * { native ; }" rule, and the JNI layer +# also reaches ByteBufferTarget's members directly. Relying on that indirectly in the vault +# unlock path is not worth the risk, so the binding is kept explicitly. +-keep class com.lambdapioneer.argon2kt.Argon2Jni { *; } +-keep class com.lambdapioneer.argon2kt.Argon2JniVerification { *; } +-keepclassmembers class com.lambdapioneer.argon2kt.ByteBufferTarget { *; } +-keepclasseswithmembernames class com.lambdapioneer.argon2kt.** { + native ; +} diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/KeyDerivationTest.kt b/app/src/androidTest/java/com/bhardwaj/passkey/KeyDerivationTest.kt new file mode 100644 index 0000000..2f7561a --- /dev/null +++ b/app/src/androidTest/java/com/bhardwaj/passkey/KeyDerivationTest.kt @@ -0,0 +1,82 @@ +package com.bhardwaj.passkey + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bhardwaj.passkey.data.security.DefaultKeyDerivation +import com.bhardwaj.passkey.data.security.KdfSpec +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import android.util.Base64 + +/** + * Exercises the real Argon2 native binding. Instrumented rather than JVM because argon2kt is a + * JNI library; these tests are what would catch the binding being stripped or renamed. + */ +@RunWith(AndroidJUnit4::class) +class KeyDerivationTest { + + private val derivation = DefaultKeyDerivation() + + private fun argon2(salt: ByteArray) = KdfSpec.Argon2id( + saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP), + // Deliberately small so the test suite stays quick; production uses the defaults. + memoryKiB = 1024, + iterations = 1, + parallelism = 1 + ) + + @Test + fun argon2_is_deterministic_for_the_same_password_and_salt() { + val salt = derivation.newSalt() + val a = derivation.derive("correct horse".toCharArray(), argon2(salt)) + val b = derivation.derive("correct horse".toCharArray(), argon2(salt)) + assertThat(a).isEqualTo(b) + assertThat(a).hasLength(32) + } + + @Test + fun argon2_differs_for_a_different_password() { + val salt = derivation.newSalt() + val a = derivation.derive("correct horse".toCharArray(), argon2(salt)) + val b = derivation.derive("incorrect horse".toCharArray(), argon2(salt)) + assertThat(a).isNotEqualTo(b) + } + + @Test + fun argon2_differs_for_a_different_salt() { + val password = "correct horse".toCharArray() + val a = derivation.derive(password.copyOf(), argon2(derivation.newSalt())) + val b = derivation.derive(password.copyOf(), argon2(derivation.newSalt())) + assertThat(a).isNotEqualTo(b) + } + + @Test + fun pbkdf2_path_also_works() { + // Kept alive in the sealed hierarchy so KDF parameters can move without a format break. + val spec = KdfSpec.Pbkdf2HmacSha512( + saltB64 = Base64.encodeToString(derivation.newSalt(), Base64.NO_WRAP), + iterations = 1_000 + ) + val a = derivation.derive("pw".toCharArray(), spec) + val b = derivation.derive("pw".toCharArray(), spec) + assertThat(a).isEqualTo(b) + assertThat(a).hasLength(32) + } + + @Test + fun salts_are_unique() { + val salts = List(50) { derivation.newSalt().toList() } + assertThat(salts.toSet()).hasSize(50) + } + + @Test + fun non_ascii_passwords_round_trip() { + // The password is converted to UTF-8 bytes without going through String, so a + // multi-byte passphrase must still derive consistently. + val salt = derivation.newSalt() + val password = "pÿssw0rd-éü-中文".toCharArray() + val a = derivation.derive(password.copyOf(), argon2(salt)) + val b = derivation.derive(password.copyOf(), argon2(salt)) + assertThat(a).isEqualTo(b) + } +} diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/VaultMigrationTest.kt b/app/src/androidTest/java/com/bhardwaj/passkey/VaultMigrationTest.kt new file mode 100644 index 0000000..5f7b10b --- /dev/null +++ b/app/src/androidTest/java/com/bhardwaj/passkey/VaultMigrationTest.kt @@ -0,0 +1,190 @@ +package com.bhardwaj.passkey + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.bhardwaj.passkey.data.local.toSqlCipherRawKey +import com.bhardwaj.passkey.data.security.VaultMigration +import com.bhardwaj.passkey.utils.Constants +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import net.zetetic.database.sqlcipher.SQLiteDatabase +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.security.SecureRandom + +/** + * Covers the one-time re-key from the legacy build-time passphrase to a per-install key. + * + * This is the highest-risk operation in the app: it rewrites the file every user's passwords + * live in. The assertions below are deliberately about the properties that would cause silent + * data loss rather than a visible error. + */ +@RunWith(AndroidJUnit4::class) +class VaultMigrationTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val dbFile: File get() = context.getDatabasePath(Constants.PASS_KEY_DATABASE) + + /** Matches what a build with no PASS_PHRASE in local.properties embeds. */ + private val legacyKey = BuildConfig.LEGACY_PASS_PHRASE.toByteArray() + + private lateinit var migration: VaultMigration + + @Before + fun setUp() { + System.loadLibrary("sqlcipher") + migration = VaultMigration(context) + deleteDatabaseFiles() + } + + @After + fun tearDown() = deleteDatabaseFiles() + + private fun deleteDatabaseFiles() { + dbFile.parentFile?.listFiles() + ?.filter { it.name.startsWith(Constants.PASS_KEY_DATABASE) } + ?.forEach { it.delete() } + } + + private fun newDek() = ByteArray(32).also { SecureRandom().nextBytes(it) } + + /** Builds a database shaped like one produced by the shipped 5.5.x app. */ + private fun createLegacyDatabase(entries: Int = 3, userVersion: Int = 3) { + dbFile.parentFile?.mkdirs() + val db = SQLiteDatabase.openOrCreateDatabase(dbFile.absolutePath, legacyKey, null, null) + db.execSQL( + "CREATE TABLE IF NOT EXISTS ${Constants.PREVIEW_TABLE} (" + + "previewId INTEGER PRIMARY KEY AUTOINCREMENT, heading TEXT NOT NULL, " + + "categoryName TEXT NOT NULL, sequence INTEGER NOT NULL DEFAULT 0)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS ${Constants.DETAILS_TABLE} (" + + "detailsId INTEGER PRIMARY KEY AUTOINCREMENT, previewId INTEGER NOT NULL, " + + "question TEXT NOT NULL, answer TEXT NOT NULL, " + + "sequence INTEGER NOT NULL DEFAULT 0, isSecret INTEGER NOT NULL DEFAULT 0)" + ) + repeat(entries) { index -> + db.execSQL( + "INSERT INTO ${Constants.PREVIEW_TABLE} (heading, categoryName, sequence) " + + "VALUES ('Entry $index', 'BANKS', $index)" + ) + db.execSQL( + "INSERT INTO ${Constants.DETAILS_TABLE} " + + "(previewId, question, answer, sequence, isSecret) " + + "VALUES (${index + 1}, 'Password', 'secret-value-$index', 0, 1)" + ) + } + db.version = userVersion + db.close() + } + + private fun openWith(key: ByteArray, block: (SQLiteDatabase) -> T): T? = runCatching { + SQLiteDatabase.openDatabase( + dbFile.absolutePath, key, null, SQLiteDatabase.OPEN_READONLY, null, null + ).use(block) + }.getOrNull() + + @Test + fun migration_preserves_every_row() { + createLegacyDatabase(entries = 25) + val dek = newDek() + + val result = runBlocking { migration.migrate(dek) } + + assertThat(result).isInstanceOf(VaultMigration.Result.Migrated::class.java) + val migrated = result as VaultMigration.Result.Migrated + assertThat(migrated.previewRows).isEqualTo(25) + assertThat(migrated.detailRows).isEqualTo(25) + + val answers = openWith(dek.toSqlCipherRawKey()) { db -> + db.rawQuery("SELECT answer FROM ${Constants.DETAILS_TABLE} ORDER BY detailsId", null) + .use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } + } + } + assertThat(answers).hasSize(25) + assertThat(answers).contains("secret-value-0") + assertThat(answers).contains("secret-value-24") + } + + @Test + fun migration_preserves_user_version() { + // The single highest-severity failure mode in this release. sqlcipher_export copies + // schema and rows but NOT user_version; if it is lost, Room sees version 0 on an + // already-migrated database and either re-runs migrations against migrated tables or + // destructively recreates them, depending on configuration. + createLegacyDatabase(userVersion = 3) + val dek = newDek() + + runBlocking { migration.migrate(dek) } + + val version = openWith(dek.toSqlCipherRawKey()) { it.version } + assertThat(version).isEqualTo(3) + } + + @Test + fun migrated_database_no_longer_opens_with_the_legacy_key() { + createLegacyDatabase() + val dek = newDek() + + runBlocking { migration.migrate(dek) } + + assertThat(openWith(legacyKey) { it.version }).isNull() + assertThat(openWith(dek.toSqlCipherRawKey()) { it.version }).isNotNull() + } + + @Test + fun migration_is_idempotent_when_rerun_with_the_same_key() { + createLegacyDatabase(entries = 5) + val dek = newDek() + runBlocking { migration.migrate(dek) } + + // A second pass finds a database the legacy keys cannot open, and must fail loudly + // rather than destroying it. + val second = runBlocking { migration.migrate(dek) } + assertThat(second).isInstanceOf(VaultMigration.Result.Failed::class.java) + assertThat((second as VaultMigration.Result.Failed).reason) + .isEqualTo(VaultMigration.FailureReason.NO_LEGACY_KEY_WORKS) + + // The data is still there and still readable under the new key. + val count = openWith(dek.toSqlCipherRawKey()) { db -> + db.rawQuery("SELECT count(*) FROM ${Constants.PREVIEW_TABLE}", null) + .use { if (it.moveToFirst()) it.getInt(0) else -1 } + } + assertThat(count).isEqualTo(5) + } + + @Test + fun migration_leaves_the_original_intact_when_it_cannot_run() { + createLegacyDatabase(entries = 4) + + // Block the export by occupying the sidecar path with a non-empty directory, which the + // migration cannot delete or write through. This is the property that matters: the + // original database must survive a failed export untouched. + val blocker = File(dbFile.parentFile, "${Constants.PASS_KEY_DATABASE}.new") + blocker.mkdirs() + File(blocker, "occupied").writeText("x") + + val result = runBlocking { migration.migrate(newDek()) } + blocker.deleteRecursively() + + assertThat(result).isInstanceOf(VaultMigration.Result.Failed::class.java) + // The legacy database must still open, which is what lets the failure screen offer + // an export rather than stranding the user. + assertThat(migration.canOpenWithLegacyKey()).isTrue() + val count = openWith(legacyKey) { db -> + db.rawQuery("SELECT count(*) FROM ${Constants.PREVIEW_TABLE}", null) + .use { if (it.moveToFirst()) it.getInt(0) else -1 } + } + assertThat(count).isEqualTo(4) + } + + @Test + fun no_database_is_not_an_error() { + assertThat(runBlocking { migration.migrate(newDek()) }) + .isEqualTo(VaultMigration.Result.NotNeeded) + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt new file mode 100644 index 0000000..8f66eff --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt @@ -0,0 +1,87 @@ +package com.bhardwaj.passkey.data.local + +import android.app.Application +import androidx.room.Room +import com.bhardwaj.passkey.utils.Constants +import com.bhardwaj.passkey.utils.MIGRATION_1_2 +import com.bhardwaj.passkey.utils.MIGRATION_2_3 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import java.util.Arrays +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Opens the encrypted database once a key is available, and closes it on lock. + * + * Previously the database was built inside a `@Singleton @Provides`, so any ViewModel injecting + * the repository forced it open at construction time - before any authentication had happened. + * That is incompatible with a key that only exists after a biometric unlock. + */ +@Singleton +class VaultDatabaseProvider @Inject constructor( + private val application: Application +) { + private val mutex = Mutex() + + private val _database = MutableStateFlow(null) + val database: StateFlow = _database.asStateFlow() + + /** Kept so it can be zeroed on lock; see [closeAndWipe]. */ + private var keyBytes: ByteArray? = null + + val isOpen: Boolean get() = _database.value != null + + suspend fun open(dek: ByteArray): PassKeyDatabase = mutex.withLock { + _database.value?.let { return@withLock it } + withContext(Dispatchers.IO) { + val key = dek.toSqlCipherRawKey() + val db = Room.databaseBuilder( + context = application, + klass = PassKeyDatabase::class.java, + name = Constants.PASS_KEY_DATABASE + ) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .openHelperFactory( + // clearPassphrase = false: the default zeroes the array after the first + // open, and Room may reopen the helper (after close, or a migration), which + // would then fail. We own the lifetime of these bytes instead. + SupportOpenHelperFactory(key, null, false) + ) + .build() + keyBytes = key + _database.value = db + db + } + } + + /** Suspends until the vault is unlocked. */ + suspend fun requireDb(): PassKeyDatabase = _database.filterNotNull().first() + + suspend fun closeAndWipe() = mutex.withLock { + withContext(Dispatchers.IO) { + _database.value?.let { runCatching { it.close() } } + _database.value = null + keyBytes?.let { Arrays.fill(it, 0) } + keyBytes = null + } + } +} + +/** + * SQLCipher raw-key syntax: `x'<64 hex chars>'`. + * + * Passing the key as a passphrase would make SQLCipher run its own PBKDF2 - 256,000 iterations + * by default in v4 - on every open. The DEK is already 256 bits of entropy from SecureRandom, so + * that work buys nothing and would put a visible stall behind every unlock. + */ +internal fun ByteArray.toSqlCipherRawKey(): ByteArray = + ("x'" + joinToString("") { "%02x".format(it) } + "'").toByteArray(Charsets.US_ASCII) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/AesGcm.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/AesGcm.kt new file mode 100644 index 0000000..460abba --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/AesGcm.kt @@ -0,0 +1,41 @@ +package com.bhardwaj.passkey.data.security + +import java.security.GeneralSecurityException +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** AES-256-GCM for wrapping the DEK under a password-derived key. */ +internal object AesGcm { + + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_BITS = 128 + private const val IV_BYTES = 12 + + private val secureRandom = SecureRandom() + + fun encrypt(key: ByteArray, plaintext: ByteArray): Pair { + val iv = ByteArray(IV_BYTES).also(secureRandom::nextBytes) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_BITS, iv) + ) + return iv to cipher.doFinal(plaintext) + } + + /** Returns null on a bad tag, which for a password slot means the password was wrong. */ + fun decrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): ByteArray? = try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_BITS, iv) + ) + cipher.doFinal(ciphertext) + } catch (_: GeneralSecurityException) { + null + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt index e8311e2..9ed4e91 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt @@ -3,6 +3,7 @@ package com.bhardwaj.passkey.data.security import android.content.Context import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import com.bhardwaj.passkey.data.local.VaultDatabaseProvider import com.bhardwaj.passkey.data.repository.DataStoreRepository import com.bhardwaj.passkey.utils.SecureClipboard import dagger.hilt.android.qualifiers.ApplicationContext @@ -24,6 +25,7 @@ import javax.inject.Singleton class AppLockObserver @Inject constructor( @param:ApplicationContext private val context: Context, private val session: VaultSession, + private val vault: VaultDatabaseProvider, private val dataStoreRepository: DataStoreRepository ) : DefaultLifecycleObserver { @@ -61,7 +63,12 @@ class AppLockObserver @Inject constructor( fun lockNow(reason: LockReason) { // Clear any copied secret first: once locked there is no UI left to offer a "clear" action. SecureClipboard.clearIfOurs(context) - session.lock(reason) + scope.launch { + // Closing the database is the part that actually revokes access: it drops the + // SQLCipher handle and zeroes the key bytes, so locking is no longer just a boolean. + vault.closeAndWipe() + session.lock(reason) + } } suspend fun timeout(): AutoLockTimeout = AutoLockTimeout.fromMillis( diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/BiometricAuthenticator.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/BiometricAuthenticator.kt new file mode 100644 index 0000000..c31d959 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/BiometricAuthenticator.kt @@ -0,0 +1,149 @@ +package com.bhardwaj.passkey.data.security + +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.crypto.Cipher +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +/** + * Suspend wrapper over [BiometricPrompt]. + * + * Replaces a fire-and-forget callback style whose only effect was setting a flag that nothing + * read, so authentication was a UI gate rather than a cryptographic one. + * + * Two distinct prompts exist because of a hard constraint in androidx.biometric 1.1.0: calling + * `authenticate(promptInfo, cryptoObject)` with DEVICE_CREDENTIAL among the allowed + * authenticators throws IllegalArgumentException below API 30. Since minSdk is 28, the crypto + * path must be BIOMETRIC_STRONG only, and the credential path must run without a CryptoObject + * and rely on a time-bound key instead. + */ +@Singleton +class BiometricAuthenticator @Inject constructor() { + + sealed interface Outcome { + data class Success(val cipher: Cipher?) : Outcome + data object UserCancelled : Outcome + data object Lockout : Outcome + data object NoneEnrolled : Outcome + data class Failed(val code: Int, val message: CharSequence) : Outcome + } + + fun canAuthenticate(activity: FragmentActivity): Int = + BiometricManager.from(activity).canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + + fun hasStrongBiometric(activity: FragmentActivity): Boolean = + BiometricManager.from(activity) + .canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + + /** + * Strong-biometric prompt bound to [cipher]. BIOMETRIC_WEAK is deliberately excluded: a weak + * biometric can never back a Keystore key, and the previous implementation allowed it. + */ + suspend fun authenticateWithCrypto( + activity: FragmentActivity, + cipher: Cipher, + title: String, + subtitle: String, + negativeButton: String + ): Outcome = prompt( + activity = activity, + cryptoObject = BiometricPrompt.CryptoObject(cipher), + allowed = BIOMETRIC_STRONG, + title = title, + subtitle = subtitle, + negativeButton = negativeButton + ) + + /** + * Device-credential (or biometric) prompt with no CryptoObject. Opens the validity window + * for the time-bound CRED key, which the caller must use promptly. + */ + suspend fun authenticateForTimeBoundKey( + activity: FragmentActivity, + title: String, + subtitle: String + ): Outcome = prompt( + activity = activity, + cryptoObject = null, + allowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BIOMETRIC_STRONG or DEVICE_CREDENTIAL + } else { + DEVICE_CREDENTIAL + }, + title = title, + subtitle = subtitle, + negativeButton = null + ) + + private suspend fun prompt( + activity: FragmentActivity, + cryptoObject: BiometricPrompt.CryptoObject?, + allowed: Int, + title: String, + subtitle: String, + negativeButton: String? + ): Outcome = suspendCancellableCoroutine { continuation -> + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(allowed) + .apply { + // Required whenever DEVICE_CREDENTIAL is not among the allowed authenticators. + if (allowed and DEVICE_CREDENTIAL == 0 && negativeButton != null) { + setNegativeButtonText(negativeButton) + } + } + .build() + + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(code: Int, message: CharSequence) { + if (!continuation.isActive) return + continuation.resume( + when (code) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED -> Outcome.UserCancelled + + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> Outcome.Lockout + + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> Outcome.NoneEnrolled + + else -> Outcome.Failed(code, message) + } + ) + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + if (!continuation.isActive) return + continuation.resume(Outcome.Success(result.cryptoObject?.cipher)) + } + + // onAuthenticationFailed is a single rejected attempt, not a terminal state; + // the prompt stays up and the framework will call error() if it gives up. + } + ) + + continuation.invokeOnCancellation { prompt.cancelAuthentication() } + + if (cryptoObject != null) { + prompt.authenticate(info, cryptoObject) + } else { + prompt.authenticate(info) + } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt new file mode 100644 index 0000000..897942a --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt @@ -0,0 +1,305 @@ +package com.bhardwaj.passkey.data.security + +import android.app.KeyguardManager +import android.content.Context +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.util.Base64 +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Arrays +import javax.crypto.Cipher +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the vault's data encryption key (DEK) and the slots that wrap it. + * + * Replaces a single build-time constant shared by every install. The DEK is 32 random bytes + * generated once per install and never stored bare; each slot holds it wrapped under a different + * unlock factor, so any one of them opens the vault. + */ +@Singleton +class DatabaseKeyManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val slotStore: KeySlotStore, + private val keyStore: KeyStoreWrapper, + private val keyDerivation: KeyDerivation, + private val biometrics: BiometricAuthenticator +) { + private val mutex = Mutex() + private val secureRandom = SecureRandom() + + sealed interface UnlockResult { + data class Success(val dek: ByteArray) : UnlockResult + /** Both Keystore slots are gone; only the recovery password remains. */ + data object NeedsRecoveryPassword : UnlockResult + /** The device has no lock screen, so no auth-bound key can exist. */ + data object NeedsDeviceLock : UnlockResult + data object Cancelled : UnlockResult + data object Lockout : UnlockResult + data class Error(val cause: Throwable) : UnlockResult + } + + sealed interface ProvisionResult { + data class Success(val dek: ByteArray) : ProvisionResult + data class Error(val cause: Throwable) : ProvisionResult + } + + fun isProvisioned(): Boolean = slotStore.exists() + + fun isDeviceSecure(): Boolean = + context.getSystemService()?.isDeviceSecure == true + + suspend fun migrationState(): MigrationState = + slotStore.load()?.migrationState ?: MigrationState.NONE + + /** + * Creates a fresh DEK and all three slots. The recovery password is mandatory: it is the + * only slot that survives the user removing their device lock, which destroys every + * auth-bound Keystore key. Without it there is a reachable state with permanent data loss. + */ + suspend fun provision(recoveryPassword: CharArray): ProvisionResult = mutex.withLock { + withContext(Dispatchers.IO) { + runCatching { + val dek = ByteArray(DEK_BYTES).also(secureRandom::nextBytes) + val slots = buildList { + // Recovery first, and unconditionally: it is the only slot that does not + // depend on the device having a lock screen, and KeySlotFile refuses to + // persist without it. + add(wrapWithPassword(SlotId.REC, dek, recoveryPassword)) + + keyStore.delete(KeyStoreWrapper.ALIAS_BIO) + keyStore.delete(KeyStoreWrapper.ALIAS_CRED) + + // Auth-bound Keystore keys cannot be generated at all on a device with no + // lock screen, and the biometric variant additionally needs an enrolment. + // Each is attempted independently so a device with a PIN but no fingerprint + // still gets CRED, and a device with neither still gets a usable vault + // protected by the recovery password alone. + if (isDeviceSecure()) { + runCatching { + keyStore.createBiometricKey() + wrapWithKeystore(SlotId.BIO, KeyStoreWrapper.ALIAS_BIO, dek) + }.onSuccess { add(it) } + .onFailure { keyStore.delete(KeyStoreWrapper.ALIAS_BIO) } + + runCatching { + keyStore.createCredentialKey() + wrapWithKeystore(SlotId.CRED, KeyStoreWrapper.ALIAS_CRED, dek) + }.onSuccess { add(it) } + .onFailure { keyStore.delete(KeyStoreWrapper.ALIAS_CRED) } + } + } + slotStore.save( + KeySlotFile( + migrationState = MigrationState.PENDING, + dekCheck = dekCheck(dek), + slots = slots + ) + ) + ProvisionResult.Success(dek) + }.getOrElse { ProvisionResult.Error(it) } + } + } + + /** + * Unlock chain: BIO, then CRED, then recovery. + * + * KeyPermanentlyInvalidatedException is thrown by Cipher.init, before any prompt appears, so + * an invalidated enrollment is detected silently and the user never sees a broken prompt. + */ + suspend fun unlock( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButton: String + ): UnlockResult = mutex.withLock { + val file = slotStore.load() ?: return@withLock UnlockResult.NeedsRecoveryPassword + + if (!isDeviceSecure()) { + // Removing the lock screen destroys every auth-bound Keystore key. Re-adding it does + // not bring them back, so recovery is the only remaining door. + return@withLock UnlockResult.NeedsRecoveryPassword + } + + tryBiometricSlot(file, activity, title, subtitle, negativeButton)?.let { return@withLock it } + tryCredentialSlot(file, activity, title, subtitle)?.let { return@withLock it } + UnlockResult.NeedsRecoveryPassword + } + + private suspend fun tryBiometricSlot( + file: KeySlotFile, + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButton: String + ): UnlockResult? { + val slot = file.slot(SlotId.BIO) as? KeySlot.Keystore ?: return null + val cipher = try { + keyStore.initDecryptCipher(slot.alias, slot.ivB64.decodeB64()) + } catch (_: KeyPermanentlyInvalidatedException) { + return null // enrollment changed - fall through to CRED + } catch (_: Exception) { + return null + } + + return when (val outcome = biometrics.authenticateWithCrypto( + activity, cipher, title, subtitle, negativeButton + )) { + is BiometricAuthenticator.Outcome.Success -> runCatching { + val dek = outcome.cipher!!.doFinal(slot.wrappedKeyB64.decodeB64()) + UnlockResult.Success(dek) + }.getOrElse { UnlockResult.Error(it) } + + BiometricAuthenticator.Outcome.UserCancelled -> UnlockResult.Cancelled + BiometricAuthenticator.Outcome.Lockout -> UnlockResult.Lockout + BiometricAuthenticator.Outcome.NoneEnrolled -> null + is BiometricAuthenticator.Outcome.Failed -> null + } + } + + private suspend fun tryCredentialSlot( + file: KeySlotFile, + activity: FragmentActivity, + title: String, + subtitle: String + ): UnlockResult? { + val slot = file.slot(SlotId.CRED) as? KeySlot.Keystore ?: return null + if (!keyStore.exists(slot.alias)) return null + + when (biometrics.authenticateForTimeBoundKey(activity, title, subtitle)) { + is BiometricAuthenticator.Outcome.Success -> Unit + BiometricAuthenticator.Outcome.UserCancelled -> return UnlockResult.Cancelled + BiometricAuthenticator.Outcome.Lockout -> return UnlockResult.Lockout + else -> return null + } + + return try { + // Must happen inside the key's validity window opened by the prompt above. + val cipher = keyStore.initDecryptCipher(slot.alias, slot.ivB64.decodeB64()) + val dek = cipher.doFinal(slot.wrappedKeyB64.decodeB64()) + repairBiometricSlot(file, dek) + UnlockResult.Success(dek) + } catch (_: KeyPermanentlyInvalidatedException) { + null + } catch (_: Exception) { + null + } + } + + /** Unwraps via the recovery password and rebuilds both Keystore slots. */ + suspend fun unlockWithRecoveryPassword(password: CharArray): UnlockResult = mutex.withLock { + withContext(Dispatchers.IO) { + val file = slotStore.load() + ?: return@withContext UnlockResult.Error(IllegalStateException("no key slots")) + val slot = file.slot(SlotId.REC) as? KeySlot.Password + ?: return@withContext UnlockResult.Error(IllegalStateException("no recovery slot")) + + val wrappingKey = keyDerivation.derive(password, slot.kdf) + try { + val dek = AesGcm.decrypt( + key = wrappingKey, + iv = slot.ivB64.decodeB64(), + ciphertext = slot.wrappedKeyB64.decodeB64() + ) ?: return@withContext UnlockResult.Cancelled + + if (dekCheck(dek) != file.dekCheck) { + return@withContext UnlockResult.Error(IllegalStateException("key mismatch")) + } + if (isDeviceSecure()) rebuildKeystoreSlots(file, dek) + UnlockResult.Success(dek) + } finally { + Arrays.fill(wrappingKey, 0) + } + } + } + + suspend fun changeRecoveryPassword(dek: ByteArray, newPassword: CharArray) = + mutex.withLock { + val file = slotStore.load() ?: return@withLock + slotStore.save(file.withSlot(wrapWithPassword(SlotId.REC, dek, newPassword))) + } + + suspend fun markMigrationState(state: MigrationState) = mutex.withLock { + slotStore.load()?.let { slotStore.save(it.copy(migrationState = state)) } + } + + private suspend fun repairBiometricSlot(file: KeySlotFile, dek: ByteArray) { + if (!isDeviceSecure()) return + runCatching { + keyStore.delete(KeyStoreWrapper.ALIAS_BIO) + keyStore.createBiometricKey() + slotStore.save(file.withSlot(wrapWithKeystore(SlotId.BIO, KeyStoreWrapper.ALIAS_BIO, dek))) + } + } + + private suspend fun rebuildKeystoreSlots(file: KeySlotFile, dek: ByteArray) { + if (!isDeviceSecure()) return + runCatching { + var updated = file + keyStore.delete(KeyStoreWrapper.ALIAS_CRED) + keyStore.createCredentialKey() + updated = updated.withSlot( + wrapWithKeystore(SlotId.CRED, KeyStoreWrapper.ALIAS_CRED, dek) + ) + runCatching { + keyStore.delete(KeyStoreWrapper.ALIAS_BIO) + keyStore.createBiometricKey() + wrapWithKeystore(SlotId.BIO, KeyStoreWrapper.ALIAS_BIO, dek) + }.onSuccess { updated = updated.withSlot(it) } + slotStore.save(updated) + } + } + + private fun wrapWithKeystore(id: SlotId, alias: String, dek: ByteArray): KeySlot.Keystore { + val cipher: Cipher = keyStore.initEncryptCipher(alias) + val wrapped = cipher.doFinal(dek) + return KeySlot.Keystore( + id = id, + alias = alias, + ivB64 = cipher.iv.encodeB64(), + wrappedKeyB64 = wrapped.encodeB64() + ) + } + + private fun wrapWithPassword( + id: SlotId, + dek: ByteArray, + password: CharArray + ): KeySlot.Password { + val spec = KdfSpec.Argon2id(saltB64 = keyDerivation.newSalt().encodeB64()) + val wrappingKey = keyDerivation.derive(password, spec) + try { + val (iv, ciphertext) = AesGcm.encrypt(wrappingKey, dek) + return KeySlot.Password( + id = id, + kdf = spec, + ivB64 = iv.encodeB64(), + wrappedKeyB64 = ciphertext.encodeB64() + ) + } finally { + Arrays.fill(wrappingKey, 0) + } + } + + private fun dekCheck(dek: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(dek + DEK_CHECK_CONTEXT.toByteArray(Charsets.US_ASCII)) + .take(16) + .joinToString("") { "%02x".format(it) } + + private companion object { + const val DEK_BYTES = 32 + const val DEK_CHECK_CONTEXT = "passkey-dek-v1" + } +} + +private fun ByteArray.encodeB64(): String = Base64.encodeToString(this, Base64.NO_WRAP) +private fun String.decodeB64(): ByteArray = Base64.decode(this, Base64.NO_WRAP) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/KdfSpec.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/KdfSpec.kt new file mode 100644 index 0000000..a9788f5 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/KdfSpec.kt @@ -0,0 +1,48 @@ +package com.bhardwaj.passkey.data.security + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Parameters used to turn a user password into a key-wrapping key. + * + * Stored alongside the wrapped key so parameters can be raised later without breaking existing + * slots: an old slot keeps its own spec and is re-wrapped with the current one next time the + * password is verified. + */ +@Serializable +sealed interface KdfSpec { + + val saltB64: String + + @Serializable + @SerialName("argon2id") + data class Argon2id( + override val saltB64: String, + val memoryKiB: Int = DEFAULT_MEMORY_KIB, + val iterations: Int = DEFAULT_ITERATIONS, + val parallelism: Int = DEFAULT_PARALLELISM + ) : KdfSpec { + companion object { + /** + * Tuned to stay usable on low-end devices: 32 MiB is a compromise, since the vault + * has to unlock on whatever phone the user has. Backup files, which leave the + * device and face offline attack, use higher settings. + */ + const val DEFAULT_MEMORY_KIB = 32 * 1024 + const val DEFAULT_ITERATIONS = 3 + const val DEFAULT_PARALLELISM = 2 + } + } + + @Serializable + @SerialName("pbkdf2-hmac-sha512") + data class Pbkdf2HmacSha512( + override val saltB64: String, + val iterations: Int = DEFAULT_ITERATIONS + ) : KdfSpec { + companion object { + const val DEFAULT_ITERATIONS = 600_000 + } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/KeyDerivation.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/KeyDerivation.kt new file mode 100644 index 0000000..0b918df --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/KeyDerivation.kt @@ -0,0 +1,83 @@ +package com.bhardwaj.passkey.data.security + +import android.util.Base64 +import com.lambdapioneer.argon2kt.Argon2Kt +import com.lambdapioneer.argon2kt.Argon2Mode +import java.security.SecureRandom +import java.util.Arrays +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.inject.Inject +import javax.inject.Singleton + +/** Derives a 256-bit key-wrapping key from a user password. */ +interface KeyDerivation { + fun derive(password: CharArray, spec: KdfSpec): ByteArray + fun newSalt(): ByteArray +} + +@Singleton +class DefaultKeyDerivation @Inject constructor() : KeyDerivation { + + private val secureRandom = SecureRandom() + private val argon2 by lazy { Argon2Kt() } + + override fun newSalt(): ByteArray = ByteArray(SALT_BYTES).also(secureRandom::nextBytes) + + override fun derive(password: CharArray, spec: KdfSpec): ByteArray { + val salt = Base64.decode(spec.saltB64, Base64.NO_WRAP) + return when (spec) { + is KdfSpec.Argon2id -> deriveArgon2(password, salt, spec) + is KdfSpec.Pbkdf2HmacSha512 -> derivePbkdf2(password, salt, spec.iterations) + } + } + + private fun deriveArgon2( + password: CharArray, + salt: ByteArray, + spec: KdfSpec.Argon2id + ): ByteArray { + val passwordBytes = password.toUtf8Bytes() + try { + return argon2.hash( + mode = Argon2Mode.ARGON2_ID, + password = passwordBytes, + salt = salt, + tCostInIterations = spec.iterations, + mCostInKibibyte = spec.memoryKiB, + parallelism = spec.parallelism, + hashLengthInBytes = KEY_BYTES + ).rawHashAsByteArray() + } finally { + Arrays.fill(passwordBytes, 0) + } + } + + private fun derivePbkdf2( + password: CharArray, + salt: ByteArray, + iterations: Int + ): ByteArray { + val keySpec = PBEKeySpec(password, salt, iterations, KEY_BYTES * 8) + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") + .generateSecret(keySpec).encoded + } finally { + keySpec.clearPassword() + } + } + + /** Avoids String, which would leave an immutable copy of the password on the heap. */ + private fun CharArray.toUtf8Bytes(): ByteArray { + val buffer = java.nio.CharBuffer.wrap(this) + val encoded = Charsets.UTF_8.encode(buffer) + val bytes = ByteArray(encoded.remaining()) + encoded.get(bytes) + return bytes + } + + private companion object { + const val SALT_BYTES = 16 + const val KEY_BYTES = 32 + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/KeySlotStore.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/KeySlotStore.kt new file mode 100644 index 0000000..2f06961 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/KeySlotStore.kt @@ -0,0 +1,57 @@ +package com.bhardwaj.passkey.data.security + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists [KeySlotFile] to `filesDir/vault/keyslots.json`. + * + * Deliberately not DataStore Preferences: that file is a plaintext proto inside the app's + * shared-preferences backup domain. The wrapped key is safe at rest either way, but keeping it + * in a dedicated file makes the backup exclusions unambiguous. + * + * Writes go to a temp file, are fsynced and then renamed, so a crash mid-write cannot leave a + * truncated slot file — which would be an unopenable vault. + */ +@Singleton +class KeySlotStore @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val json = Json { ignoreUnknownKeys = false; encodeDefaults = true } + + private val directory: File get() = File(context.filesDir, "vault") + private val file: File get() = File(directory, "keyslots.json") + private val tempFile: File get() = File(directory, "keyslots.json.tmp") + + /** Synchronous on purpose: the cold-start branch needs it before anything else runs. */ + fun exists(): Boolean = file.exists() + + suspend fun load(): KeySlotFile? = withContext(Dispatchers.IO) { + if (!file.exists()) return@withContext null + runCatching { json.decodeFromString(file.readText()) }.getOrNull() + } + + suspend fun save(slotFile: KeySlotFile) = withContext(Dispatchers.IO) { + directory.mkdirs() + val encoded = json.encodeToString(slotFile) + FileOutputStream(tempFile).use { out -> + out.write(encoded.toByteArray(Charsets.UTF_8)) + out.flush() + out.fd.sync() + } + check(tempFile.renameTo(file)) { "could not replace key slot file" } + } + + suspend fun clear() = withContext(Dispatchers.IO) { + file.delete() + tempFile.delete() + Unit + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/KeySlots.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/KeySlots.kt new file mode 100644 index 0000000..cced1f5 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/KeySlots.kt @@ -0,0 +1,93 @@ +package com.bhardwaj.passkey.data.security + +import kotlinx.serialization.Serializable + +enum class SlotId { + /** Per-use strong biometric. Invalidated when biometric enrollment changes. */ + BIO, + + /** Device credential, time-bound. Survives biometric enrollment changes. */ + CRED, + + /** Recovery password. Survives even the device lock being removed entirely. */ + REC +} + +enum class MigrationState { + /** No vault has been provisioned yet. */ + NONE, + + /** Key slots are written but the database has not finished being re-keyed. */ + PENDING, + + DONE +} + +@Serializable +sealed interface KeySlot { + val id: SlotId + + /** Base64 GCM IV, then the wrapped data encryption key. */ + val ivB64: String + val wrappedKeyB64: String + + @Serializable + data class Keystore( + override val id: SlotId, + val alias: String, + override val ivB64: String, + override val wrappedKeyB64: String + ) : KeySlot + + @Serializable + data class Password( + override val id: SlotId, + val kdf: KdfSpec, + override val ivB64: String, + override val wrappedKeyB64: String + ) : KeySlot +} + +/** + * The on-disk key slot file. + * + * Every slot wraps the *same* data encryption key, so unlocking needs any one of them. That is + * what makes losing a fingerprint enrollment survivable without ever creating an unauthenticated + * path to the key. + * + * [dekCheck] is an HMAC-style digest of the DEK. GCM already authenticates each unwrap, so this + * is belt-and-braces, but it also gives the re-key a cheap post-condition: after unwrapping + * through a different slot we can confirm we got the same key before touching the database. + */ +@Serializable +data class KeySlotFile( + val version: Int = CURRENT_VERSION, + val migrationState: MigrationState, + val dekCheck: String, + val slots: List +) { + init { + // Invariants worth crashing over rather than silently persisting: a slot file with no + // recovery slot, or none at all, is an unopenable vault. + require(slots.isNotEmpty()) { "key slot file must contain at least one slot" } + require(slots.any { it.id == SlotId.REC }) { + "the recovery slot may never be absent: it is the only one that survives the device " + + "lock being removed" + } + require(slots.map { it.id }.distinct().size == slots.size) { "duplicate slot ids" } + } + + fun slot(id: SlotId): KeySlot? = slots.firstOrNull { it.id == id } + + fun withSlot(slot: KeySlot): KeySlotFile = + copy(slots = slots.filterNot { it.id == slot.id } + slot) + + fun withoutSlot(id: SlotId): KeySlotFile { + require(id != SlotId.REC) { "the recovery slot may not be removed" } + return copy(slots = slots.filterNot { it.id == id }) + } + + companion object { + const val CURRENT_VERSION = 1 + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt new file mode 100644 index 0000000..d0dd7db --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt @@ -0,0 +1,152 @@ +package com.bhardwaj.passkey.data.security + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Creates and uses the AndroidKeyStore keys that wrap the vault's data encryption key. + * + * Two aliases exist, deliberately with different auth properties: + * + * - [ALIAS_BIO] requires per-use strong biometric auth and is bound to the current biometric + * enrollment, so adding or removing a fingerprint invalidates it. That is the desired + * property for the everyday unlock path. + * - [ALIAS_CRED] accepts device credential or biometrics with a short validity window and is + * *not* invalidated by biometric enrollment, which is what lets the app self-heal after the + * user changes their fingerprints without ever creating an unauthenticated path to the key. + */ +@Singleton +class KeyStoreWrapper @Inject constructor( + @param:ApplicationContext private val context: Context +) { + companion object { + const val ALIAS_BIO = "passkey_dek_wrap_bio" + const val ALIAS_CRED = "passkey_dek_wrap_cred" + + private const val PROVIDER = "AndroidKeyStore" + private const val TRANSFORMATION = + "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/" + + "${KeyProperties.ENCRYPTION_PADDING_NONE}" + private const val GCM_TAG_BITS = 128 + private const val KEY_BITS = 256 + + /** Seconds the credential-backed key stays usable after a successful auth. */ + const val CRED_VALIDITY_SECONDS = 30 + } + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(PROVIDER).apply { load(null) } + } + + fun exists(alias: String): Boolean = runCatching { keyStore.containsAlias(alias) } + .getOrDefault(false) + + fun getOrNull(alias: String): SecretKey? = runCatching { + keyStore.getKey(alias, null) as? SecretKey + }.getOrNull() + + fun delete(alias: String) { + runCatching { keyStore.deleteEntry(alias) } + } + + fun createBiometricKey(): SecretKey = generate(ALIAS_BIO, biometricOnly = true) + + fun createCredentialKey(): SecretKey = generate(ALIAS_CRED, biometricOnly = false) + + /** + * @throws android.security.keystore.KeyPermanentlyInvalidatedException when the enrollment + * backing the key has changed. Thrown by init, *before* any prompt is shown, which is what + * lets the caller fall through to another slot without the user seeing a broken prompt. + */ + fun initEncryptCipher(alias: String): Cipher = + Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, requireKey(alias)) + } + + /** @throws android.security.keystore.KeyPermanentlyInvalidatedException — see above. */ + fun initDecryptCipher(alias: String, iv: ByteArray): Cipher = + Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, requireKey(alias), GCMParameterSpec(GCM_TAG_BITS, iv)) + } + + private fun requireKey(alias: String): SecretKey = + getOrNull(alias) ?: error("Keystore alias $alias is missing") + + private fun generate(alias: String, biometricOnly: Boolean): SecretKey { + // StrongBox can throw at generateKey() even when the feature flag is advertised, so it + // is attempted and then abandoned rather than trusted. + return runCatching { generate(alias, biometricOnly, strongBox = supportsStrongBox()) } + .recoverCatching { error -> + if (error is StrongBoxUnavailableException) { + delete(alias) + generate(alias, biometricOnly, strongBox = false) + } else { + throw error + } + } + .getOrThrow() + } + + private fun generate(alias: String, biometricOnly: Boolean, strongBox: Boolean): SecretKey { + val builder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_BITS) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(true) + // API 28 exactly, which is this app's minSdk: the key is unusable while the device + // is locked, so a stolen unlocked-but-idle phone is not a bypass. + .setUnlockedDeviceRequired(true) + + if (biometricOnly) { + // Invalidating on enrollment change is the point of this slot; the CRED slot exists + // precisely so that invalidation is recoverable. + builder.setInvalidatedByBiometricEnrollment(true) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val authTypes = if (biometricOnly) { + KeyProperties.AUTH_BIOMETRIC_STRONG + } else { + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL + } + builder.setUserAuthenticationParameters( + if (biometricOnly) 0 else CRED_VALIDITY_SECONDS, + authTypes + ) + } else { + // Deprecated at API 30 but the only option on 28-29. -1 means per-use auth. + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds( + if (biometricOnly) -1 else CRED_VALIDITY_SECONDS + ) + } + + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).apply { + init(builder.build()) + }.generateKey() + } + + private fun supportsStrongBox(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt new file mode 100644 index 0000000..e2ee090 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt @@ -0,0 +1,189 @@ +package com.bhardwaj.passkey.data.security + +import android.content.Context +import android.os.StatFs +import com.bhardwaj.passkey.BuildConfig +import com.bhardwaj.passkey.data.local.toSqlCipherRawKey +import com.bhardwaj.passkey.utils.Constants +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import net.zetetic.database.sqlcipher.SQLiteDatabase +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Re-keys a pre-5.7 database from the build-time constant to the per-install DEK. + * + * Uses `sqlcipher_export()` into a sidecar file rather than `PRAGMA rekey`. Rekey re-encrypts + * every page in place with no journal-level protection across the whole operation, so an + * interruption leaves an unopenable file. Exporting keeps the original untouched until a rename, + * which makes the operation atomic by construction. + */ +@Singleton +class VaultMigration @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val mutex = Mutex() + + sealed interface Result { + data object NotNeeded : Result + data class Migrated(val previewRows: Int, val detailRows: Int) : Result + data class Failed(val reason: FailureReason, val cause: Throwable? = null) : Result + } + + enum class FailureReason { + NO_LEGACY_KEY_WORKS, INSUFFICIENT_SPACE, EXPORT_FAILED, VERIFY_FAILED, SWAP_FAILED + } + + /** + * Passphrases to try against an un-migrated database, in order. + * + * "null" is a genuine candidate, not a placeholder: when PASS_PHRASE is absent from + * local.properties, `properties.getProperty(...)` returns null and the build embeds the + * four-character string "null" as the key. + */ + private val legacyCandidates: List + get() = listOfNotNull(BuildConfig.LEGACY_PASS_PHRASE, "null").distinct() + + fun databaseExists(): Boolean = context.getDatabasePath(Constants.PASS_KEY_DATABASE).exists() + + suspend fun migrate(dek: ByteArray): Result = mutex.withLock { + withContext(Dispatchers.IO) { runMigration(dek) } + } + + private fun runMigration(dek: ByteArray): Result { + val original = context.getDatabasePath(Constants.PASS_KEY_DATABASE) + if (!original.exists()) return Result.NotNeeded + + // Step 0 - pre-flight. The export needs room for a full second copy. + val stat = StatFs(original.parentFile!!.absolutePath) + if (stat.availableBytes < original.length() * 3) { + return Result.Failed(FailureReason.INSUFFICIENT_SPACE) + } + + val newFile = File(original.parentFile, "${Constants.PASS_KEY_DATABASE}.new") + val backupFile = File(original.parentFile, "${Constants.PASS_KEY_DATABASE}.bak") + listOf(newFile, sidecar(newFile, "-wal"), sidecar(newFile, "-shm")).forEach { it.delete() } + + var userVersion = 0 + var previewCount = 0 + var detailCount = 0 + + // Step 1 - open with a legacy candidate and capture what must be preserved. + val legacy = openWithAnyLegacyKey(original) + ?: return Result.Failed(FailureReason.NO_LEGACY_KEY_WORKS) + + try { + userVersion = legacy.version + previewCount = legacy.countOf(Constants.PREVIEW_TABLE) + detailCount = legacy.countOf(Constants.DETAILS_TABLE) + + // Fold the WAL back in: stale -wal pages encrypted under the old key could otherwise + // resurrect after the swap. + legacy.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { it.moveToFirst() } + legacy.rawQuery("PRAGMA journal_mode = DELETE", null).use { it.moveToFirst() } + + // Step 4 - export into a sidecar under the new key. + val rawKey = String(dek.toSqlCipherRawKey(), Charsets.US_ASCII) + // The target is created up front rather than letting ATTACH create it. The source + // connection is opened OPEN_READWRITE without CREATE, so ATTACH of a non-existent + // file fails with SQLITE_CANTOPEN. + SQLiteDatabase.openOrCreateDatabase( + newFile.absolutePath, dek.toSqlCipherRawKey(), null, null + ).close() + + // The path is inlined rather than bound: SQLite does not accept bind parameters in + // ATTACH. + val escapedPath = newFile.absolutePath.replace("'", "''") + legacy.execSQL("ATTACH DATABASE '$escapedPath' AS newdb KEY \"$rawKey\"") + legacy.rawQuery("SELECT sqlcipher_export('newdb')", null).use { it.moveToFirst() } + // THE most important statement here. sqlcipher_export copies schema and rows but NOT + // user_version. Without this Room sees version 0 on a migrated database and either + // re-runs migrations against migrated tables or destructively recreates them. + legacy.execSQL("PRAGMA newdb.user_version = $userVersion") + legacy.execSQL("DETACH DATABASE newdb") + } catch (error: Exception) { + newFile.delete() + return Result.Failed(FailureReason.EXPORT_FAILED, error) + } finally { + runCatching { legacy.close() } + } + + // Step 5 - verify before anything destructive happens. + val verified = runCatching { + SQLiteDatabase.openDatabase( + newFile.absolutePath, dek.toSqlCipherRawKey(), null, + SQLiteDatabase.OPEN_READONLY, null, null + ).use { db -> + val integrityOk = db.rawQuery("PRAGMA cipher_integrity_check", null) + .use { !it.moveToFirst() } + integrityOk && + db.countOf(Constants.PREVIEW_TABLE) == previewCount && + db.countOf(Constants.DETAILS_TABLE) == detailCount && + db.version == userVersion + } + }.getOrDefault(false) + + if (!verified) { + newFile.delete() + return Result.Failed(FailureReason.VERIFY_FAILED) + } + + // Step 6 - swap. The original is only renamed aside, never deleted before this point. + return runCatching { + backupFile.delete() + check(original.renameTo(backupFile)) { "could not move the original aside" } + check(newFile.renameTo(original)) { "could not move the re-keyed database into place" } + listOf("-wal", "-shm", "-journal").forEach { sidecar(original, it).delete() } + Result.Migrated(previewCount, detailCount) + }.getOrElse { Result.Failed(FailureReason.SWAP_FAILED, it) } + } + + /** Called only once the new key has been confirmed to work end to end. */ + fun discardBackup() { + val original = context.getDatabasePath(Constants.PASS_KEY_DATABASE) + File(original.parentFile, "${Constants.PASS_KEY_DATABASE}.bak").delete() + } + + /** + * Still openable with the legacy key, which is what lets the failure screen offer an export + * even when the migration cannot complete. + */ + fun canOpenWithLegacyKey(): Boolean { + val original = context.getDatabasePath(Constants.PASS_KEY_DATABASE) + if (!original.exists()) return false + val db = openWithAnyLegacyKey(original) ?: return false + runCatching { db.close() } + return true + } + + private fun openWithAnyLegacyKey(file: File): SQLiteDatabase? { + for (candidate in legacyCandidates) { + val db = runCatching { + SQLiteDatabase.openDatabase( + file.absolutePath, candidate.toByteArray(), null, + SQLiteDatabase.OPEN_READWRITE, null, null + ) + }.getOrNull() + if (db != null) { + val usable = runCatching { + db.rawQuery("SELECT count(*) FROM sqlite_master", null).use { it.moveToFirst() } + }.getOrDefault(false) + if (usable) return db + runCatching { db.close() } + } + } + return null + } + + private fun SQLiteDatabase.countOf(table: String): Int = + rawQuery("SELECT count(*) FROM $table", null).use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } + + private fun sidecar(file: File, suffix: String) = File(file.absolutePath + suffix) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt index 3e6cd52..c255e40 100644 --- a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt +++ b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt @@ -1,56 +1,39 @@ package com.bhardwaj.passkey.di -import android.app.Application import android.content.Context -import androidx.room.Room -import com.bhardwaj.passkey.BuildConfig -import com.bhardwaj.passkey.data.local.PassKeyDatabase +import com.bhardwaj.passkey.data.local.VaultDatabaseProvider import com.bhardwaj.passkey.data.repository.DataStoreRepository import com.bhardwaj.passkey.data.repository.PasskeyRepository +import com.bhardwaj.passkey.data.security.DefaultKeyDerivation +import com.bhardwaj.passkey.data.security.KeyDerivation import com.bhardwaj.passkey.domain.repository.PasskeyRepositoryImpl -import com.bhardwaj.passkey.utils.Constants -import com.bhardwaj.passkey.utils.MIGRATION_1_2 -import com.bhardwaj.passkey.utils.MIGRATION_2_3 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { + + // PassKeyDatabase is deliberately NOT provided here any more. It can only be built once a + // data encryption key exists, which requires the user to have authenticated, so it is owned + // by VaultDatabaseProvider and opened on unlock. + @Provides @Singleton - fun provideDatabase(appContext: Application): PassKeyDatabase { - return Room.databaseBuilder( - context = appContext, - klass = PassKeyDatabase::class.java, - name = Constants.PASS_KEY_DATABASE - ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) - // FIXME(5.7.0): this is a single build-time constant, identical for every install - // and recoverable from the APK, so the database is encrypted against a filesystem - // thief and nothing else. It is replaced in 5.7.0 by a per-install random key - // wrapped by an AndroidKeyStore key; this value then survives only to open and - // re-key pre-5.7 databases. - .openHelperFactory( - factory = SupportOpenHelperFactory(BuildConfig.LEGACY_PASS_PHRASE.toByteArray()) - ) - .build() - } + fun providePasskeyRepository(vault: VaultDatabaseProvider): PasskeyRepository = + PasskeyRepositoryImpl(vault) @Provides @Singleton - fun provideTodoRepository(db: PassKeyDatabase): PasskeyRepository { - return PasskeyRepositoryImpl(db, db.previewDao, db.detailsDao) - } + fun provideKeyDerivation(impl: DefaultKeyDerivation): KeyDerivation = impl @Provides @Singleton fun provideDataStoreRepository( @ApplicationContext context: Context ) = DataStoreRepository(context = context) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/SecurityEvents.kt b/app/src/main/java/com/bhardwaj/passkey/domain/events/SecurityEvents.kt deleted file mode 100644 index 3de1fee..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/SecurityEvents.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.bhardwaj.passkey.domain.events - -sealed interface SecurityEvents { - data object OnAuthenticationSucceeded : SecurityEvents - data object OnAuthenticationError : SecurityEvents - data object AddPasswordToTheDeviceFirst : SecurityEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt index 42ebe27..fd9b9a6 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt @@ -1,6 +1,10 @@ package com.bhardwaj.passkey.domain.repository import androidx.room.withTransaction +import com.bhardwaj.passkey.data.local.VaultDatabaseProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import com.bhardwaj.passkey.data.local.PassKeyDatabase import com.bhardwaj.passkey.data.local.dao.DetailsDao import com.bhardwaj.passkey.data.local.dao.PreviewDao @@ -9,29 +13,39 @@ import com.bhardwaj.passkey.data.local.entity.Preview import com.bhardwaj.passkey.data.repository.PasskeyRepository import kotlinx.coroutines.flow.Flow +@OptIn(ExperimentalCoroutinesApi::class) class PasskeyRepositoryImpl( - private val database: PassKeyDatabase, - private val previewDao: PreviewDao, - private val detailsDao: DetailsDao + private val vault: VaultDatabaseProvider ) : PasskeyRepository { + private suspend fun previewDao(): PreviewDao = vault.requireDb().previewDao + private suspend fun detailsDao(): DetailsDao = vault.requireDb().detailsDao + + /** + * Room Flows are resolved through the vault's current database rather than captured once. + * + * This is load-bearing for locking: flatMapLatest detaches every collector when the database + * closes and re-subscribes when it reopens. Holding a DAO captured at construction would + * leave collectors on a closed database and throw + * "attempt to re-open an already-closed object" on the next emission. + */ + private fun vaultFlow(block: (PassKeyDatabase) -> Flow>): Flow> = + vault.database.flatMapLatest { db -> db?.let(block) ?: flowOf(emptyList()) } + override suspend fun runInTransaction(block: suspend () -> R): R = - database.withTransaction { block() } + vault.requireDb().withTransaction { block() } override suspend fun deleteAll() { - detailsDao.deleteAllDetails() - previewDao.deleteAllPreviews() - } - override fun getDetails(): Flow> { - return detailsDao.getDetails() + detailsDao().deleteAllDetails() + previewDao().deleteAllPreviews() } + override fun getDetails(): Flow> = vaultFlow { it.detailsDao.getDetails() } - override fun getDetailsByPreviewId(previewId: Long): Flow> { - return detailsDao.getDetailsByPreviewId(previewId) - } + override fun getDetailsByPreviewId(previewId: Long): Flow> = + vaultFlow { it.detailsDao.getDetailsByPreviewId(previewId) } override suspend fun getDetailById(detailId: Long): Details? { - return detailsDao.getDetailById(detailId) + return detailsDao().getDetailById(detailId) } override suspend fun getDetailByContent( @@ -39,49 +53,47 @@ class PasskeyRepositoryImpl( question: String, answer: String ): Details? { - return detailsDao.getDetailByContent(previewId, question, answer) + return detailsDao().getDetailByContent(previewId, question, answer) } override suspend fun upsertDetails(details: Details): Long { - return detailsDao.upsertDetails(details) + return detailsDao().upsertDetails(details) } override suspend fun deleteDetail(details: Details) { - return detailsDao.deleteDetail(details) + return detailsDao().deleteDetail(details) } override suspend fun deleteDetailByPreviewId(previewId: Long) { - return detailsDao.deleteDetailByPreviewId(previewId) + return detailsDao().deleteDetailByPreviewId(previewId) } override suspend fun updateDetailSequence(detailId: Long, sequence: Long) { - return detailsDao.updateDetailSequence(detailId, sequence) + return detailsDao().updateDetailSequence(detailId, sequence) } - override fun getPreviews(): Flow> { - return previewDao.getPreviews() - } + override fun getPreviews(): Flow> = vaultFlow { it.previewDao.getPreviews() } override suspend fun getPreviewById(previewId: Long): Preview? { - return previewDao.getPreviewById(previewId) + return previewDao().getPreviewById(previewId) } override suspend fun getPreviewByHeading( previewHeading: String, categoryName: String ): Preview? { - return previewDao.getPreviewByHeading(previewHeading, categoryName) + return previewDao().getPreviewByHeading(previewHeading, categoryName) } override suspend fun upsertPreview(previews: Preview): Long { - return previewDao.upsertPreview(previews) + return previewDao().upsertPreview(previews) } override suspend fun deletePreview(previews: Preview) { - return previewDao.deletePreview(previews) + return previewDao().deletePreview(previews) } override suspend fun updatePreviewSequence(previewId: Long, sequence: Long) { - return previewDao.updatePreviewSequence(previewId, sequence) + return previewDao().updatePreviewSequence(previewId, sequence) } } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SecurityViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SecurityViewModel.kt deleted file mode 100644 index ba6bc40..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SecurityViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.bhardwaj.passkey.domain.viewModels - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.bhardwaj.passkey.data.security.VaultSession -import com.bhardwaj.passkey.domain.events.SecurityEvents -import com.bhardwaj.passkey.presentation.navigation.Routes -import com.bhardwaj.passkey.utils.UiEvents -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class SecurityViewModel @Inject constructor( - private val vaultSession: VaultSession -) : ViewModel() { - private val _uiEvents = Channel() - val uiEvents = _uiEvents.receiveAsFlow() - - private var isAuthenticated by mutableStateOf(false) - - var needToOpenAlertDialog by mutableStateOf(false) - private set - - fun onEvent(event: SecurityEvents) { - when (event) { - SecurityEvents.AddPasswordToTheDeviceFirst -> { - isAuthenticated = false - needToOpenAlertDialog = true - } - - SecurityEvents.OnAuthenticationError -> { - isAuthenticated = false - needToOpenAlertDialog = true - } - - SecurityEvents.OnAuthenticationSucceeded -> { - // Previously isAuthenticated was written and never read, so navigation was the - // only gate. The session is now the thing the rest of the app observes. - vaultSession.onUnlocked() - isAuthenticated = true - needToOpenAlertDialog = false - sendUiEvents(UiEvents.Navigate(Routes.PREVIEW_PAGE)) - } - } - } - - private fun sendUiEvents(events: UiEvents) { - viewModelScope.launch { - _uiEvents.send(events) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt new file mode 100644 index 0000000..c4cdef6 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt @@ -0,0 +1,165 @@ +package com.bhardwaj.passkey.domain.viewModels + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bhardwaj.passkey.data.local.VaultDatabaseProvider +import com.bhardwaj.passkey.data.security.DatabaseKeyManager +import com.bhardwaj.passkey.data.security.MigrationState +import com.bhardwaj.passkey.data.security.VaultMigration +import com.bhardwaj.passkey.data.security.VaultSession +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Arrays +import javax.inject.Inject + +/** + * Drives everything between app launch and an open vault: first-run provisioning, the one-time + * re-key of a pre-5.7 database, and the ordinary unlock. + */ +@HiltViewModel +class VaultGateViewModel @Inject constructor( + private val keyManager: DatabaseKeyManager, + private val migration: VaultMigration, + private val vault: VaultDatabaseProvider, + private val session: VaultSession +) : ViewModel() { + + sealed interface State { + data object Checking : State + + /** + * A recovery password must be chosen before the vault can be used. + * [isExistingVault] distinguishes an upgrading user, whose data is about to be re-keyed, + * from a first-run user. + */ + data class SetUpRecovery(val isExistingVault: Boolean) : State + + /** Ready to prompt for biometrics. */ + data object Locked : State + data object Authenticating : State + data object Migrating : State + + /** [canExportVault] is true while the legacy key still opens the original database. */ + data class MigrationFailed(val reason: String, val canExportVault: Boolean) : State + + data object NeedsRecoveryPassword : State + data object NeedsDeviceLock : State + data object Unlocked : State + data class Error(val message: String) : State + } + + private val _state = MutableStateFlow(State.Checking) + val state: StateFlow = _state.asStateFlow() + + /** Set when a wrong recovery password was entered, so the UI can say so. */ + private val _recoveryFailed = MutableStateFlow(false) + val recoveryFailed: StateFlow = _recoveryFailed.asStateFlow() + + fun start() { + viewModelScope.launch { + _state.value = if (!keyManager.isProvisioned()) { + State.SetUpRecovery(isExistingVault = migration.databaseExists()) + } else { + // A PENDING migration state is also just Locked: the re-key is resumable + // because the original database is untouched until the final rename. + State.Locked + } + } + } + + /** First run, or the first launch after upgrading from a pre-5.7 build. */ + fun onRecoveryPasswordChosen(password: CharArray) { + viewModelScope.launch { + _state.value = State.Checking + when (val result = keyManager.provision(password)) { + is DatabaseKeyManager.ProvisionResult.Error -> + _state.value = State.Error(result.cause.message ?: "provisioning failed") + + is DatabaseKeyManager.ProvisionResult.Success -> { + Arrays.fill(password, NUL) + runMigrationThenOpen(result.dek) + } + } + } + } + + fun onUnlockRequested( + activity: FragmentActivity, + title: String, + subtitle: String, + negativeButton: String + ) { + viewModelScope.launch { + _state.value = State.Authenticating + when (val result = keyManager.unlock(activity, title, subtitle, negativeButton)) { + is DatabaseKeyManager.UnlockResult.Success -> runMigrationThenOpen(result.dek) + DatabaseKeyManager.UnlockResult.NeedsRecoveryPassword -> + _state.value = State.NeedsRecoveryPassword + DatabaseKeyManager.UnlockResult.NeedsDeviceLock -> + _state.value = State.NeedsDeviceLock + DatabaseKeyManager.UnlockResult.Cancelled -> _state.value = State.Locked + DatabaseKeyManager.UnlockResult.Lockout -> _state.value = State.Locked + is DatabaseKeyManager.UnlockResult.Error -> + _state.value = State.Error(result.cause.message ?: "unlock failed") + } + } + } + + fun onRecoveryPasswordEntered(password: CharArray) { + viewModelScope.launch { + _state.value = State.Checking + _recoveryFailed.value = false + val result = keyManager.unlockWithRecoveryPassword(password) + Arrays.fill(password, NUL) + when (result) { + is DatabaseKeyManager.UnlockResult.Success -> runMigrationThenOpen(result.dek) + else -> { + _recoveryFailed.value = true + _state.value = State.NeedsRecoveryPassword + } + } + } + } + + fun onRetry() = start() + + /** + * The re-key runs after unlock, because the new key must be wrapped by an auth-bound + * Keystore key before it is used to encrypt anything. + */ + private suspend fun runMigrationThenOpen(dek: ByteArray) { + if (keyManager.migrationState() != MigrationState.DONE && migration.databaseExists()) { + _state.value = State.Migrating + val result = migration.migrate(dek) + if (result is VaultMigration.Result.Failed) { + _state.value = State.MigrationFailed( + reason = result.reason.name, + canExportVault = migration.canOpenWithLegacyKey() + ) + return + } + } + keyManager.markMigrationState(MigrationState.DONE) + migration.discardBackup() + + val opened = runCatching { vault.open(dek) } + if (opened.isFailure) { + _state.value = State.Error( + opened.exceptionOrNull()?.message ?: "could not open the vault" + ) + return + } + session.onUnlocked() + _state.value = State.Unlocked + // The key now lives only inside VaultDatabaseProvider's SQLCipher handle. + Arrays.fill(dek, 0) + } + + private companion object { + val NUL = Char(0) + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt index 4357a95..9ef4a07 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt @@ -9,7 +9,7 @@ import androidx.navigation.navArgument import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailScreen import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingScreen import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewScreen -import com.bhardwaj.passkey.presentation.screens.security_screen.SecurityScreen +import com.bhardwaj.passkey.presentation.screens.security_screen.VaultGateScreen import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsScreen import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashPage import com.bhardwaj.passkey.utils.Categories @@ -60,7 +60,14 @@ fun NavGraph( onNavigate = { route -> navController.navigate(route) }) } composable(route = NavScreens.SecurityPage.route) { - SecurityScreen(navController = navController) + VaultGateScreen( + onUnlocked = { + navController.navigate(NavScreens.PreviewPage.route) { + // Nothing behind the gate should survive it. + popUpTo(0) { inclusive = true } + } + } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt deleted file mode 100644 index fa49a77..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/SecurityPage.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.bhardwaj.passkey.presentation.screens.security_screen - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.provider.Settings -import androidx.activity.compose.LocalActivity -import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -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.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.SecureFlagPolicy -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.navigation.NavController -import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.SecurityEvents -import com.bhardwaj.passkey.domain.viewModels.SecurityViewModel -import com.bhardwaj.passkey.presentation.theme.Poppins -import com.bhardwaj.passkey.utils.asString -import com.bhardwaj.passkey.utils.UiEvents -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@Composable -fun SecurityScreen( - navController: NavController, - viewModel: SecurityViewModel = hiltViewModel() -) { - val snackBarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - - val context = LocalContext.current - - LaunchedEffect(key1 = true) { - viewModel.uiEvents.collect { event -> - when (event) { - is UiEvents.Navigate -> { - navController.popBackStack() - navController.navigate(event.route) - } - - is UiEvents.ShowSnackBar -> { - scope.launch { - snackBarHostState.showSnackbar( - message = event.message.asString(context), - actionLabel = event.action?.asString(context) - ) - } - } - - else -> Unit - } - } - } - - val activity = LocalActivity.current as FragmentActivity - val startForResult = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == FragmentActivity.RESULT_CANCELED) { - viewModel.onEvent(SecurityEvents.AddPasswordToTheDeviceFirst) - } - viewModel.onEvent(SecurityEvents.OnAuthenticationError) - } - - val title = stringResource(id = R.string.app_name) - val subTitle = stringResource(id = R.string.welcome_to_pass_key) - - LaunchedEffect(key1 = Unit) { - biometricPrompt( - title = title, - subTitle = subTitle, - context = context, - activity = activity, - viewModel = viewModel, - scope = scope, - startForResult = startForResult - ) - } - - Scaffold( - snackbarHost = { SnackbarHost(snackBarHostState) } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.Center - ) { - if (viewModel.needToOpenAlertDialog) { - Dialog( - onDismissRequest = {}, properties = DialogProperties( - securePolicy = SecureFlagPolicy.SecureOn, - dismissOnBackPress = false, - dismissOnClickOutside = false - ) - ) { - Card( - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth(), - elevation = CardDefaults.cardElevation(8.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(id = R.string.security_heading), - fontSize = 16.sp, - fontStyle = FontStyle.Normal, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground - ) - Text( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 24.dp - ), - text = stringResource(id = R.string.security_description), - fontSize = 16.sp, - fontStyle = FontStyle.Normal, - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onBackground - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outline) - Text( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { - MutableInteractionSource() - }, - onClick = { - scope.launch { - biometricPrompt( - title = title, - subTitle = subTitle, - context = context, - activity = activity, - viewModel = viewModel, - scope = scope, - startForResult = startForResult - ) - } - } - ) - .padding(8.dp), - text = stringResource(id = R.string.security_button), - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onBackground, - fontStyle = FontStyle.Normal, - fontFamily = Poppins, - textAlign = TextAlign.Center - ) - } - } - } - } - } - } -} - -fun biometricPrompt( - title: String, - subTitle: String, - context: Context, - activity: FragmentActivity, - viewModel: SecurityViewModel, - scope: CoroutineScope, - startForResult: ManagedActivityResultLauncher -) { - val executor = ContextCompat.getMainExecutor(activity) - val biometricManager by lazy { BiometricManager.from(context) } - val allowedAuthenticators = BIOMETRIC_STRONG or BIOMETRIC_WEAK or DEVICE_CREDENTIAL - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subTitle) - .setAllowedAuthenticators(allowedAuthenticators) - .build() - - val biometricPrompt = BiometricPrompt( - activity, executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - viewModel.onEvent(SecurityEvents.OnAuthenticationError) - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - viewModel.onEvent(SecurityEvents.OnAuthenticationSucceeded) - } - } - ) - when (biometricManager.canAuthenticate(allowedAuthenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> { - biometricPrompt.authenticate(promptInfo) - } - - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val intent = Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply { - putExtra( - Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, - allowedAuthenticators - ) - } - scope.launch { - startForResult.launch(intent) - } - } else { - viewModel.onEvent(SecurityEvents.AddPasswordToTheDeviceFirst) - } - } - - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED, - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED, - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> { - viewModel.onEvent(SecurityEvents.OnAuthenticationError) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt new file mode 100644 index 0000000..3c39cf3 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt @@ -0,0 +1,196 @@ +package com.bhardwaj.passkey.presentation.screens.security_screen + +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.domain.viewModels.VaultGateViewModel +import com.bhardwaj.passkey.presentation.screens.settings_screen.components.BackupPasswordDialog + +/** + * The screen standing between app launch and an open vault. + * + * Replaces the previous security screen, whose only job was to show a biometric prompt and then + * navigate. It now also covers first-run key provisioning, the one-time re-key of a pre-5.7 + * database, and the recovery paths for when the device's biometric enrollment or lock screen has + * changed underneath the app. + */ +@Composable +fun VaultGateScreen( + onUnlocked: () -> Unit, + viewModel: VaultGateViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val recoveryFailed by viewModel.recoveryFailed.collectAsStateWithLifecycle() + val activity = LocalActivity.current as? FragmentActivity + val context = LocalContext.current + + val promptTitle = stringResource(R.string.app_name) + val promptSubtitle = stringResource(R.string.security_description) + val promptNegative = stringResource(R.string.cancel) + + LaunchedEffect(Unit) { viewModel.start() } + + LaunchedEffect(state) { + if (state is VaultGateViewModel.State.Unlocked) onUnlocked() + } + + // Auto-prompt on entering the locked state, so the user is not made to tap twice. + LaunchedEffect(state, activity) { + if (state is VaultGateViewModel.State.Locked && activity != null) { + viewModel.onUnlockRequested(activity, promptTitle, promptSubtitle, promptNegative) + } + } + + when (val current = state) { + is VaultGateViewModel.State.SetUpRecovery -> BackupPasswordDialog( + confirmMode = true, + titleRes = R.string.recovery_setup_title, + messageRes = if (current.isExistingVault) { + R.string.recovery_setup_message_upgrade + } else { + R.string.recovery_setup_message_new + }, + dismissible = false, + onDismiss = {}, + onConfirm = viewModel::onRecoveryPasswordChosen + ) + + VaultGateViewModel.State.NeedsRecoveryPassword -> BackupPasswordDialog( + confirmMode = false, + titleRes = R.string.recovery_unlock_title, + messageRes = if (recoveryFailed) { + R.string.recovery_unlock_wrong + } else { + R.string.recovery_unlock_message + }, + dismissible = false, + onDismiss = {}, + onConfirm = viewModel::onRecoveryPasswordEntered + ) + + VaultGateViewModel.State.Migrating -> GateMessage( + title = stringResource(R.string.migration_title), + message = stringResource(R.string.migration_message), + busy = true + ) + + VaultGateViewModel.State.Checking, + VaultGateViewModel.State.Authenticating -> GateMessage(busy = true) + + is VaultGateViewModel.State.MigrationFailed -> GateMessage( + title = stringResource(R.string.migration_failed_title), + // Deliberately does not offer "reset": the original database is intact and the only + // safe actions are retrying or getting the data out. + message = if (current.canExportVault) { + stringResource(R.string.migration_failed_message_can_export) + } else { + stringResource(R.string.migration_failed_message) + }, + primaryLabel = stringResource(R.string.migration_retry), + onPrimary = viewModel::onRetry + ) + + VaultGateViewModel.State.NeedsDeviceLock -> GateMessage( + title = stringResource(R.string.device_lock_required_title), + message = stringResource(R.string.device_lock_required_message), + primaryLabel = stringResource(R.string.device_lock_open_settings), + onPrimary = { + context.startActivity( + Intent(Settings.ACTION_SECURITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + }, + secondaryLabel = stringResource(R.string.migration_retry), + onSecondary = viewModel::onRetry + ) + + is VaultGateViewModel.State.Error -> GateMessage( + title = stringResource(R.string.something_went_wrong), + message = current.message, + primaryLabel = stringResource(R.string.migration_retry), + onPrimary = viewModel::onRetry + ) + + VaultGateViewModel.State.Locked -> GateMessage( + title = stringResource(R.string.security_heading), + message = stringResource(R.string.security_description), + primaryLabel = stringResource(R.string.security_button), + onPrimary = { + activity?.let { + viewModel.onUnlockRequested(it, promptTitle, promptSubtitle, promptNegative) + } + } + ) + + VaultGateViewModel.State.Unlocked -> Unit + } +} + +@Composable +private fun GateMessage( + title: String? = null, + message: String? = null, + busy: Boolean = false, + primaryLabel: String? = null, + onPrimary: (() -> Unit)? = null, + secondaryLabel: String? = null, + onSecondary: (() -> Unit)? = null +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (busy) CircularProgressIndicator(modifier = Modifier.padding(bottom = 24.dp)) + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + } + message?.let { + Text( + modifier = Modifier.padding(top = 12.dp), + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center + ) + } + if (primaryLabel != null && onPrimary != null) { + Button(modifier = Modifier.padding(top = 24.dp), onClick = onPrimary) { + Text(text = primaryLabel) + } + } + if (secondaryLabel != null && onSecondary != null) { + TextButton(modifier = Modifier.padding(top = 8.dp), onClick = onSecondary) { + Text(text = secondaryLabel) + } + } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/BackupPasswordDialog.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/BackupPasswordDialog.kt index 7c5fc51..3e79257 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/BackupPasswordDialog.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/BackupPasswordDialog.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen.components +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions @@ -35,7 +36,11 @@ private const val MIN_BACKUP_PASSWORD_LENGTH = 8 fun BackupPasswordDialog( confirmMode: Boolean, onDismiss: () -> Unit, - onConfirm: (CharArray) -> Unit + onConfirm: (CharArray) -> Unit, + @StringRes titleRes: Int? = null, + @StringRes messageRes: Int? = null, + /** False for gate dialogs that the user must not be able to escape. */ + dismissible: Boolean = true ) { var password by remember { mutableStateOf("") } var confirmation by remember { mutableStateOf("") } @@ -52,12 +57,16 @@ fun BackupPasswordDialog( AlertDialog( // The dialog is its own window, so it needs FLAG_SECURE in its own right. - properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), - onDismissRequest = onDismiss, + properties = DialogProperties( + securePolicy = SecureFlagPolicy.SecureOn, + dismissOnBackPress = dismissible, + dismissOnClickOutside = dismissible + ), + onDismissRequest = { if (dismissible) onDismiss() }, title = { Text( text = stringResource( - if (confirmMode) R.string.backup_password_title + titleRes ?: if (confirmMode) R.string.backup_password_title else R.string.restore_password_title ) ) @@ -66,7 +75,7 @@ fun BackupPasswordDialog( Column { Text( text = stringResource( - if (confirmMode) R.string.backup_password_message + messageRes ?: if (confirmMode) R.string.backup_password_message else R.string.restore_password_message ), style = MaterialTheme.typography.bodyMedium @@ -117,7 +126,9 @@ fun BackupPasswordDialog( }) { Text(stringResource(R.string.backup_confirm)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + if (dismissible) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } } ) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f3e1f53..e1b592d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -142,4 +142,19 @@ يحفظ PassKey بيانات تسجيل الدخول وكلمات المرور والملاحظات الخاصة الأخرى في قاعدة بيانات مشفّرة على جهازك.\n\nخزنتك لا تغادر هاتفك أبدًا. لا يوجد حساب ولا مزامنة ولا خادم لـ PassKey: التطبيق لا يجري أي طلبات شبكة من تلقاء نفسه، ولا تُرسل مدخلاتك إلى أي مكان.\n\nقاعدة البيانات مشفّرة بواسطة SQLCipher وتُفتح ببصمتك أو قفل شاشة جهازك. تُقفل الخزنة تلقائيًا عند مغادرة التطبيق، ولقطات الشاشة محظورة، وكلمات المرور المنسوخة تُعلَّم كحساسة وتُمسح من الحافظة بعد وقت قصير.\n\nيمكنك تنظيم المدخلات في فئات، وإعادة ترتيبها بالسحب، وإنشاء كلمات مرور قوية، وفحص خزنتك بحثًا عن كلمات مرور ضعيفة أو مكررة. تُصدَّر النسخ الاحتياطية كملف واحد مشفّر بكلمة مرور تختارها، ويمكن استعادتها على أي جهاز.\n\nPassKey مجاني ومفتوح المصدر، طوّره Aditya Bhardwaj. الشيفرة المصدرية الكاملة متاحة على GitHub. تبقى خزنتك على جهازك.\n\nما لا نجمعه أبدًا: لا يجمع PassKey ولا ينقل ولا يخزّن كلمات المرور أو أسماء المستخدمين أو الملاحظات أو أي مدخلات تحفظها. توجد فقط في قاعدة بيانات مشفّرة على هاتفك. لا يمكننا قراءتها ولا استعادتها لك.\n\nما يرسله التطبيق: يتضمن PassKey خدمات Google Firebase Crashlytics وAnalytics وPerformance Monitoring، وتحديثات Google Play داخل التطبيق. ترسل هذه الخدمات إلى Google تشخيصات الأعطال وأحداث الاستخدام وبيانات الأداء ومعرّفات الجهاز والتثبيت بما في ذلك معرّف إعلاني. لا تصل هذه الخدمات إلى محتوى خزنتك إطلاقًا. بسبب هذه الخدمات يحتاج التطبيق إلى إذن الإنترنت، وتخضع معالجتها للبيانات لسياسة خصوصية Google.\n\nالنسخ الاحتياطية: عند التصدير يُشفَّر الملف بكلمة مرور تختارها ويُحفظ حيث تحدد للنظام. تصبح مسؤولية هذا الملف عليك بعد ذلك؛ من يحصل على الملف وكلمة مروره معًا يستطيع قراءته. نحن لا نحتفظ بأي نسخة.\n\nنسخ الجهاز الاحتياطي: PassKey مستثنى من النسخ الاحتياطي السحابي في Android ومن النقل بين الأجهزة، لذا لا تُرفع خزنتك إلى حساب Google الخاص بك.\n\nالتغييرات: قد تُحدَّث هذه السياسة؛ وستُذكر التغييرات الجوهرية في ملاحظات إصدار التطبيق.\n\nللاستفسار: تواصل عبر yrkkh.cclub@gmail.com. باستخدامك PassKey فإنك توافق على ما يلي.\n\n1. PassKey برنامج مجاني مفتوح المصدر طوّره Aditya Bhardwaj.\n\n2. تُخزَّن خزنتك على جهازك فقط. لا توجد نسخة على أي خادم، وبالتالي لا توجد وسيلة لاستعادة بياناتك إذا فقدت جهازك أو أزلت التطبيق أو نسيت كلمة مرور النسخة الاحتياطية.\n\n3. الاحتفاظ بالنسخ الاحتياطية مسؤوليتك. صدّرها بانتظام واحفظ كلمة مرور النسخة في مكان آمن ومنفصل.\n\n4. يعتمد التطبيق على أمان البصمة أو قفل الشاشة في جهازك. إذا كان هذا القفل ضعيفًا أو مُزالًا أو مخترقًا، تقل الحماية التي يوفرها PassKey بالقدر نفسه.\n\n5. يتضمن PassKey خدمات Google Firebase وGoogle Play كما هو موضح في سياسة الخصوصية.\n\n6. يُقدَّم التطبيق \"كما هو\" دون أي ضمان صريح أو ضمني، بما في ذلك القابلية للتسويق والملاءمة لغرض معين.\n\n7. إلى الحد الذي يسمح به القانون، لا يتحمل المطوّر مسؤولية أي أضرار ناتجة عن استخدام التطبيق، بما في ذلك فقدان البيانات.\n\n8. يجوز تعديل التطبيق أو إيقافه في أي وقت. + عيّن كلمة مرور للاسترداد + يُفتح PassKey ببصمتك أو قفل الشاشة. كلمة مرور الاسترداد هذه هي الطريق البديل للدخول — ستحتاج إليها إذا غيّرت بصماتك أو أزلت قفل الجهاز. لا يمكن إعادة تعيينها، فاحفظها في مكان آمن. + يعيد هذا التحديث تشفير خزنتك بمفتاح خاص بهذا الجهاز. عيّن كلمة مرور للاسترداد أولًا: هي الطريق الوحيد للعودة إذا غيّرت بصماتك أو أزلت قفل الجهاز. لا يمكن إعادة تعيينها، فاحفظها في مكان آمن. + أدخل كلمة مرور الاسترداد + تغيّرت بصمات جهازك أو قفل الشاشة، لذا يحتاج PassKey إلى كلمة مرور الاسترداد لفتح الخزنة. + لم تنجح كلمة المرور هذه. حاول مرة أخرى. + جارٍ تأمين خزنتك + تُعاد الآن تشفير مدخلاتك بمفتاح خاص بهذا الجهاز. يُرجى إبقاء التطبيق مفتوحًا. + تعذّر إكمال تأمين الخزنة + لم يُفقد شيء ومدخلاتك كما هي. حاول مرة أخرى أو افتح التطبيق لاحقًا. + لم يُفقد شيء — خزنتك سليمة ويمكن قراءتها. حاول مرة أخرى، أو صدّر نسخة احتياطية من الإعدادات أولًا. + حاول مرة أخرى + قفل الجهاز مطلوب + يحمي PassKey خزنتك بقفل شاشة جهازك. عيّن رمز PIN أو نمطًا أو كلمة مرور أو بصمة للمتابعة. + فتح الإعدادات \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 1da4261..f6e4460 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -138,4 +138,19 @@ PassKey আপনার লগইন, পাসওয়ার্ড এবং অন্যান্য ব্যক্তিগত নোট আপনার ডিভাইসের একটি এনক্রিপ্ট করা ডেটাবেসে সংরক্ষণ করে।\n\nআপনার ভল্ট কখনও ফোন ছেড়ে যায় না। কোনো অ্যাকাউন্ট নেই, সিঙ্ক নেই, PassKey-এর কোনো সার্ভারও নেই: অ্যাপটি নিজে থেকে কোনো নেটওয়ার্ক অনুরোধ করে না এবং আপনার এন্ট্রি কোথাও পাঠানো হয় না।\n\nডেটাবেসটি SQLCipher দিয়ে এনক্রিপ্ট করা এবং আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন লক দিয়ে খোলে। অ্যাপ ছাড়লেই ভল্ট স্বয়ংক্রিয়ভাবে আবার লক হয়, স্ক্রিনশট বন্ধ, এবং কপি করা পাসওয়ার্ড সংবেদনশীল হিসেবে চিহ্নিত হয়ে অল্প পরেই ক্লিপবোর্ড থেকে মুছে যায়।\n\nআপনি এন্ট্রি বিভাগে সাজাতে, টেনে ক্রম বদলাতে, শক্তিশালী পাসওয়ার্ড তৈরি করতে এবং দুর্বল বা পুনরায় ব্যবহৃত পাসওয়ার্ড পরীক্ষা করতে পারেন। ব্যাকআপ একটি ফাইল হিসেবে রপ্তানি হয়, আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট করা, এবং যেকোনো ডিভাইসে পুনরুদ্ধার করা যায়।\n\nPassKey বিনামূল্যে ও ওপেন সোর্স, তৈরি করেছেন Aditya Bhardwaj। সম্পূর্ণ সোর্স কোড GitHub-এ পাওয়া যায়। আপনার ভল্ট আপনার ডিভাইসেই থাকে।\n\nআমরা যা কখনও সংগ্রহ করি না: PassKey আপনার সংরক্ষিত পাসওয়ার্ড, ব্যবহারকারীর নাম, নোট বা অন্য কোনো এন্ট্রি সংগ্রহ, প্রেরণ বা সঞ্চয় করে না। সেগুলি কেবল আপনার ফোনের এনক্রিপ্ট করা ডেটাবেসে থাকে। আমরা সেগুলি পড়তেও পারি না, আপনার জন্য ফিরিয়ে আনতেও পারি না।\n\nঅ্যাপ যা পাঠায়: PassKey-তে Google Firebase Crashlytics, Analytics ও Performance Monitoring এবং Google Play ইন-অ্যাপ আপডেট অন্তর্ভুক্ত। এগুলি ক্র্যাশ ডায়াগনস্টিক, ব্যবহারের ঘটনা, কর্মক্ষমতার তথ্য এবং বিজ্ঞাপন আইডিসহ ডিভাইস ও ইনস্টলেশন শনাক্তকারী Google-কে পাঠায়। আপনার ভল্টের বিষয়বস্তুতে এদের কখনও প্রবেশাধিকার নেই। এই পরিষেবাগুলির কারণে অ্যাপটির ইন্টারনেট অনুমতি প্রয়োজন, এবং তাদের তথ্য ব্যবস্থাপনা Google-এর গোপনীয়তা নীতি দ্বারা পরিচালিত।\n\nব্যাকআপ: রপ্তানির সময় ফাইলটি আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট হয় এবং আপনি সিস্টেমকে যেখানে বলবেন সেখানে সংরক্ষিত হয়। এরপর সেই ফাইলের দায়িত্ব আপনার; যার কাছে ফাইল ও পাসওয়ার্ড দুটোই থাকবে সে পড়তে পারবে। আমরা কোনো অনুলিপি রাখি না।\n\nডিভাইস ব্যাকআপ: PassKey Android ক্লাউড ব্যাকআপ ও ডিভাইস-টু-ডিভাইস স্থানান্তর থেকে বাদ, তাই আপনার ভল্ট আপনার Google অ্যাকাউন্টে আপলোড হয় না।\n\nপরিবর্তন: এই নীতি হালনাগাদ হতে পারে; গুরুত্বপূর্ণ পরিবর্তন অ্যাপের রিলিজ নোটে জানানো হবে।\n\nপ্রশ্ন: যোগাযোগ করুন yrkkh.cclub@gmail.com। PassKey ব্যবহার করে আপনি নিম্নলিখিত বিষয়ে সম্মত হচ্ছেন।\n\n১. PassKey একটি বিনামূল্যের, ওপেন-সোর্স সফটওয়্যার যা Aditya Bhardwaj তৈরি করেছেন।\n\n২. আপনার ভল্ট কেবল আপনার ডিভাইসে সংরক্ষিত। সার্ভারে কোনো অনুলিপি নেই, তাই ডিভাইস হারালে, অ্যাপ আনইনস্টল করলে বা ব্যাকআপ পাসওয়ার্ড ভুলে গেলে ডেটা ফেরানোর কোনো উপায় নেই।\n\n৩. ব্যাকআপ রাখা আপনার দায়িত্ব। নিয়মিত রপ্তানি করুন এবং ব্যাকআপ পাসওয়ার্ড নিরাপদ ও আলাদা জায়গায় রাখুন।\n\n৪. অ্যাপটি আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন-লক নিরাপত্তার উপর নির্ভরশীল। সেই লক দুর্বল, সরানো বা আপসকৃত হলে PassKey-এর সুরক্ষাও সেই অনুপাতে কমে যায়।\n\n৫. PassKey-তে গোপনীয়তা নীতিতে বর্ণিত Google Firebase ও Google Play পরিষেবা অন্তর্ভুক্ত।\n\n৬. অ্যাপ্লিকেশনটি \"যেমন আছে\" সরবরাহ করা হয়, কোনো প্রকার প্রকাশ্য বা অন্তর্নিহিত ওয়ারেন্টি ছাড়াই, যার মধ্যে বাণিজ্যিক উপযোগিতা ও নির্দিষ্ট উদ্দেশ্যের উপযুক্ততা অন্তর্ভুক্ত।\n\n৭. আইন অনুমোদিত সীমা পর্যন্ত, ডেভেলপার অ্যাপ্লিকেশন ব্যবহারের ফলে উদ্ভূত কোনো ক্ষতির জন্য, তথ্য হারানো সহ, দায়ী নন।\n\n৮. অ্যাপ্লিকেশনটি যেকোনো সময় পরিবর্তন বা বন্ধ করা হতে পারে। + পুনরুদ্ধার পাসওয়ার্ড সেট করুন + PassKey আপনার আঙুলের ছাপ বা স্ক্রিন লক দিয়ে খোলে। এই পুনরুদ্ধার পাসওয়ার্ড হলো বিকল্প পথ — আঙুলের ছাপ বদলালে বা ডিভাইস লক সরালে এটি লাগবে। এটি রিসেট করা যায় না, তাই নিরাপদ জায়গায় রাখুন। + এই আপডেট আপনার ভল্টকে এই ডিভাইসের জন্য অনন্য একটি কী দিয়ে পুনরায় এনক্রিপ্ট করে। আগে একটি পুনরুদ্ধার পাসওয়ার্ড সেট করুন: আঙুলের ছাপ বদলালে বা ডিভাইস লক সরালে এটিই একমাত্র উপায়। এটি রিসেট করা যায় না, তাই নিরাপদে রাখুন। + আপনার পুনরুদ্ধার পাসওয়ার্ড দিন + আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন লক বদলে গেছে, তাই ভল্ট খুলতে PassKey-এর পুনরুদ্ধার পাসওয়ার্ড দরকার। + এই পাসওয়ার্ড কাজ করেনি। আবার চেষ্টা করুন। + আপনার ভল্ট সুরক্ষিত করা হচ্ছে + আপনার এন্ট্রিগুলি এই ডিভাইসের অনন্য কী দিয়ে পুনরায় এনক্রিপ্ট করা হচ্ছে। অ্যাপটি খোলা রাখুন। + ভল্ট সুরক্ষিত করা শেষ করা যায়নি + কিছুই হারায়নি এবং আপনার এন্ট্রি অক্ষত আছে। আবার চেষ্টা করুন, বা পরে অ্যাপ খুলুন। + কিছুই হারায়নি — আপনার ভল্ট অক্ষত ও পঠনযোগ্য। আবার চেষ্টা করুন, বা আগে সেটিংস থেকে ব্যাকআপ নিন। + আবার চেষ্টা করুন + ডিভাইস লক প্রয়োজন + PassKey আপনার ভল্ট ডিভাইসের স্ক্রিন লক দিয়ে সুরক্ষিত রাখে। চালিয়ে যেতে পিন, প্যাটার্ন, পাসওয়ার্ড বা বায়োমেট্রিক সেট করুন। + সেটিংস খুলুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 58b1e66..9078da4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -139,4 +139,19 @@ PassKey speichert deine Zugangsdaten, Passwörter und privaten Notizen in einer verschlüsselten Datenbank auf deinem Gerät.\n\nDein Tresor verlässt dein Telefon nie. Es gibt kein Konto, keine Synchronisierung und keinen PassKey-Server: Die App stellt keine eigenen Netzwerkanfragen, und deine Einträge werden nirgendwohin übertragen.\n\nDie Datenbank ist mit SQLCipher verschlüsselt und wird mit der Biometrie oder Bildschirmsperre deines Geräts entsperrt. Der Tresor sperrt sich automatisch, wenn du die App verlässt, Screenshots sind blockiert, und kopierte Passwörter werden als vertraulich markiert und kurz darauf aus der Zwischenablage gelöscht.\n\nDu kannst Einträge in Kategorien ordnen, sie per Ziehen neu anordnen, starke Passwörter erzeugen und deinen Tresor auf schwache oder mehrfach verwendete Passwörter prüfen. Backups werden als einzelne Datei exportiert, verschlüsselt mit einem Passwort deiner Wahl, und lassen sich auf jedem Gerät wiederherstellen.\n\nPassKey ist kostenlos und quelloffen, entwickelt von Aditya Bhardwaj. Der vollständige Quellcode ist auf GitHub verfügbar. Dein Tresor bleibt auf deinem Gerät.\n\nWas wir nie erfassen: PassKey erfasst, überträgt oder speichert keine der Passwörter, Benutzernamen, Notizen oder sonstigen Einträge, die du sicherst. Sie liegen ausschließlich in einer verschlüsselten Datenbank auf deinem Telefon. Wir können sie weder lesen noch für dich wiederherstellen.\n\nWas die App sendet: PassKey enthält Google Firebase Crashlytics, Analytics und Performance Monitoring sowie Google Play In-App-Updates. Diese senden Absturzdiagnosen, Nutzungsereignisse, Leistungsdaten sowie Geräte- und Installationskennungen einschließlich einer Werbe-ID an Google. Sie haben nie Zugriff auf den Inhalt deines Tresors. Wegen dieser Dienste benötigt die App die Internetberechtigung; deren Datenverarbeitung unterliegt der Datenschutzerklärung von Google.\n\nBackups: Beim Export wird die Datei mit einem Passwort deiner Wahl verschlüsselt und dort abgelegt, wo du es dem System vorgibst. Diese Datei liegt dann in deiner Verantwortung; wer Datei und Passwort besitzt, kann sie lesen. Wir behalten keine Kopie.\n\nGerätesicherung: PassKey ist von der Android-Cloud-Sicherung und der Geräteübertragung ausgeschlossen, dein Tresor wird also nicht in dein Google-Konto hochgeladen.\n\nÄnderungen: Diese Erklärung kann aktualisiert werden; wesentliche Änderungen werden in den Versionshinweisen der App vermerkt.\n\nFragen: Kontakt yrkkh.cclub@gmail.com. Mit der Nutzung von PassKey stimmst du Folgendem zu.\n\n1. PassKey ist kostenlose, quelloffene Software, entwickelt von Aditya Bhardwaj.\n\n2. Dein Tresor wird nur auf deinem Gerät gespeichert. Es gibt keine serverseitige Kopie und damit keine Möglichkeit, deine Daten wiederherzustellen, wenn du dein Gerät verlierst, die App deinstallierst oder ein Backup-Passwort vergisst.\n\n3. Backups liegen in deiner Verantwortung. Exportiere sie regelmäßig und bewahre das Backup-Passwort sicher und getrennt auf.\n\n4. Die App stützt sich auf die biometrische Sicherung oder Bildschirmsperre deines Geräts. Ist diese schwach, entfernt oder kompromittiert, verringert sich der Schutz durch PassKey entsprechend.\n\n5. PassKey enthält Google-Firebase- und Google-Play-Dienste wie in der Datenschutzerklärung beschrieben.\n\n6. Die Anwendung wird \"wie besehen\" bereitgestellt, ohne jegliche ausdrückliche oder stillschweigende Gewährleistung, einschließlich Marktgängigkeit und Eignung für einen bestimmten Zweck.\n\n7. Soweit gesetzlich zulässig, haftet der Entwickler nicht für Schäden aus der Nutzung der Anwendung, einschließlich Datenverlust.\n\n8. Die Anwendung kann jederzeit geändert oder eingestellt werden. + Wiederherstellungspasswort festlegen + PassKey entsperrt mit Fingerabdruck oder Bildschirmsperre. Dieses Wiederherstellungspasswort ist der Ersatzweg hinein — du brauchst es, wenn du deine Fingerabdrücke änderst oder die Gerätesperre entfernst. Es lässt sich nicht zurücksetzen, bewahre es sicher auf. + Dieses Update verschlüsselt deinen Tresor mit einem gerätespezifischen Schlüssel neu. Lege zuerst ein Wiederherstellungspasswort fest: Es ist der einzige Weg zurück, wenn du deine Fingerabdrücke änderst oder die Gerätesperre entfernst. Es lässt sich nicht zurücksetzen. + Wiederherstellungspasswort eingeben + Die Biometrie oder Bildschirmsperre deines Geräts hat sich geändert, daher braucht PassKey dein Wiederherstellungspasswort. + Dieses Passwort hat nicht funktioniert. Versuche es erneut. + Tresor wird gesichert + Deine Einträge werden mit einem gerätespezifischen Schlüssel neu verschlüsselt. Bitte lass die App geöffnet. + Sicherung des Tresors nicht abgeschlossen + Es ging nichts verloren, deine Einträge sind unverändert. Versuche es erneut oder öffne die App später. + Es ging nichts verloren — dein Tresor ist intakt und lesbar. Versuche es erneut oder exportiere zuerst ein Backup in den Einstellungen. + Erneut versuchen + Gerätesperre erforderlich + PassKey schützt deinen Tresor mit der Bildschirmsperre deines Geräts. Richte PIN, Muster, Passwort oder Biometrie ein, um fortzufahren. + Einstellungen öffnen \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 78136fd..5e6366b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -139,4 +139,19 @@ PassKey guarda tus inicios de sesión, contraseñas y otras notas privadas en una base de datos cifrada en tu dispositivo.\n\nTu caja fuerte nunca sale del teléfono. No hay cuenta, ni sincronización, ni servidor de PassKey: la aplicación no realiza peticiones de red propias y tus entradas nunca se transmiten a ninguna parte.\n\nLa base de datos se cifra con SQLCipher y se desbloquea con la biometría o el bloqueo de pantalla de tu dispositivo. La caja fuerte se vuelve a bloquear automáticamente al salir de la aplicación, las capturas de pantalla están bloqueadas y las contraseñas copiadas se marcan como sensibles y se borran del portapapeles poco después.\n\nPuedes organizar las entradas en categorías, reordenarlas arrastrando, generar contraseñas seguras y revisar tu caja fuerte en busca de contraseñas débiles o repetidas. Las copias de seguridad se exportan como un único archivo cifrado con una contraseña que tú eliges y pueden restaurarse en cualquier dispositivo.\n\nPassKey es gratuita y de código abierto, desarrollada por Aditya Bhardwaj. El código fuente completo está disponible en GitHub. Tu caja fuerte permanece en tu dispositivo.\n\nLo que nunca recopilamos: PassKey no recopila, transmite ni almacena las contraseñas, nombres de usuario, notas ni ninguna otra entrada que guardes. Solo existen en una base de datos cifrada en tu teléfono. No podemos leerlas ni recuperarlas por ti.\n\nLo que sí envía la aplicación: PassKey incluye Google Firebase Crashlytics, Analytics y Performance Monitoring, y las actualizaciones integradas de Google Play. Estos servicios envían diagnósticos de fallos, eventos de uso, trazas de rendimiento e identificadores de dispositivo e instalación, incluido un identificador de publicidad, a Google. Nunca tienen acceso al contenido de tu caja fuerte. Por estos servicios la aplicación requiere el permiso de internet, y su tratamiento de datos se rige por la política de privacidad de Google.\n\nCopias de seguridad: al exportar una copia, el archivo se cifra con una contraseña que tú eliges y se guarda donde le indiques al sistema. Ese archivo queda bajo tu responsabilidad; cualquiera que obtenga el archivo y su contraseña podrá leerlo. No conservamos ninguna copia.\n\nCopia del dispositivo: PassKey está excluida de la copia de seguridad en la nube de Android y de la transferencia entre dispositivos, así que tu caja fuerte no se sube a tu cuenta de Google.\n\nCambios: esta política puede actualizarse; los cambios importantes se indicarán en las notas de versión de la aplicación.\n\nDudas: escribe a yrkkh.cclub@gmail.com. Al usar PassKey aceptas lo siguiente.\n\n1. PassKey es software gratuito y de código abierto desarrollado por Aditya Bhardwaj.\n\n2. Tu caja fuerte se almacena únicamente en tu dispositivo. No hay copia en servidor y, por tanto, no existe forma de recuperar tus datos si pierdes el dispositivo, desinstalas la aplicación u olvidas una contraseña de copia de seguridad.\n\n3. Mantener copias de seguridad es responsabilidad tuya. Expórtalas con regularidad y guarda la contraseña de la copia en un lugar seguro y separado.\n\n4. La aplicación depende de la seguridad biométrica o del bloqueo de pantalla de tu dispositivo. Si ese bloqueo es débil, se elimina o queda comprometido, la protección que ofrece PassKey se reduce en consecuencia.\n\n5. PassKey incluye servicios de Google Firebase y Google Play según se describe en la Política de Privacidad.\n\n6. La aplicación se proporciona \"tal cual\", sin garantía de ningún tipo, expresa o implícita, incluidas las de comerciabilidad e idoneidad para un fin determinado.\n\n7. En la medida permitida por la ley, el desarrollador no se hace responsable de los daños derivados del uso de la aplicación, incluida la pérdida de datos.\n\n8. La aplicación puede modificarse o descontinuarse en cualquier momento. + Establece una contraseña de recuperación + PassKey se desbloquea con tu huella o el bloqueo de pantalla. Esta contraseña de recuperación es la vía alternativa: la necesitarás si cambias tus huellas o quitas el bloqueo del dispositivo. No se puede restablecer, guárdala en un lugar seguro. + Esta actualización vuelve a cifrar tu caja fuerte con una clave única de este dispositivo. Establece primero una contraseña de recuperación: es la única forma de volver a entrar si cambias tus huellas o quitas el bloqueo. No se puede restablecer. + Introduce tu contraseña de recuperación + La biometría o el bloqueo de pantalla de tu dispositivo han cambiado, así que PassKey necesita tu contraseña de recuperación. + Esa contraseña no funcionó. Inténtalo de nuevo. + Asegurando tu caja fuerte + Volviendo a cifrar tus entradas con una clave única de este dispositivo. Mantén la aplicación abierta. + No se pudo terminar de asegurar la caja fuerte + No se perdió nada y tus entradas están intactas. Inténtalo de nuevo o vuelve a abrir la app más tarde. + No se perdió nada: tu caja fuerte está intacta y se puede leer. Inténtalo de nuevo, o exporta antes una copia desde Ajustes. + Reintentar + Se requiere bloqueo de dispositivo + PassKey protege tu caja fuerte con el bloqueo de pantalla. Configura un PIN, patrón, contraseña o biometría para continuar. + Abrir ajustes diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d246b44..476a7af 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -139,4 +139,19 @@ PassKey enregistre vos identifiants, mots de passe et autres notes privées dans une base de données chiffrée sur votre appareil.\n\nVotre coffre ne quitte jamais votre téléphone. Il n\'y a ni compte, ni synchronisation, ni serveur PassKey : l\'application n\'effectue aucune requête réseau de son propre fait et vos entrées ne sont transmises nulle part.\n\nLa base de données est chiffrée avec SQLCipher et déverrouillée par la biométrie ou le verrouillage d\'écran de votre appareil. Le coffre se reverrouille automatiquement lorsque vous quittez l\'application, les captures d\'écran sont bloquées, et les mots de passe copiés sont marqués comme sensibles puis effacés du presse-papiers peu après.\n\nVous pouvez classer vos entrées par catégories, les réorganiser par glissement, générer des mots de passe robustes et vérifier votre coffre à la recherche de mots de passe faibles ou réutilisés. Les sauvegardes sont exportées sous forme d\'un fichier unique chiffré avec un mot de passe de votre choix, restaurable sur n\'importe quel appareil.\n\nPassKey est gratuite et open source, développée par Aditya Bhardwaj. Le code source complet est disponible sur GitHub. Votre coffre reste sur votre appareil.\n\nCe que nous ne collectons jamais : PassKey ne collecte, ne transmet et ne stocke aucun des mots de passe, identifiants, notes ou autres entrées que vous enregistrez. Ils n\'existent que dans une base de données chiffrée sur votre téléphone. Nous ne pouvons ni les lire, ni les récupérer pour vous.\n\nCe que l\'application envoie : PassKey intègre Google Firebase Crashlytics, Analytics et Performance Monitoring, ainsi que les mises à jour intégrées de Google Play. Ces services envoient à Google des diagnostics de plantage, des événements d\'utilisation, des traces de performance et des identifiants d\'appareil et d\'installation, dont un identifiant publicitaire. Ils n\'ont jamais accès au contenu de votre coffre. En raison de ces services, l\'application requiert l\'autorisation d\'accès à Internet, et leur traitement des données relève de la politique de confidentialité de Google.\n\nSauvegardes : lorsque vous exportez une sauvegarde, le fichier est chiffré avec un mot de passe de votre choix et enregistré à l\'emplacement que vous indiquez au système. Ce fichier relève ensuite de votre responsabilité ; quiconque obtient à la fois le fichier et son mot de passe peut le lire. Nous n\'en conservons aucune copie.\n\nSauvegarde de l\'appareil : PassKey est exclue de la sauvegarde cloud Android et du transfert entre appareils, votre coffre n\'est donc pas envoyé vers votre compte Google.\n\nModifications : cette politique peut évoluer ; les changements importants seront signalés dans les notes de version.\n\nQuestions : contactez yrkkh.cclub@gmail.com. En utilisant PassKey, vous acceptez ce qui suit.\n\n1. PassKey est un logiciel gratuit et open source développé par Aditya Bhardwaj.\n\n2. Votre coffre est stocké uniquement sur votre appareil. Il n\'existe aucune copie côté serveur, et donc aucun moyen de récupérer vos données si vous perdez votre appareil, désinstallez l\'application ou oubliez un mot de passe de sauvegarde.\n\n3. La conservation des sauvegardes vous incombe. Exportez-les régulièrement et conservez le mot de passe de sauvegarde en lieu sûr et séparé.\n\n4. L\'application repose sur la sécurité biométrique ou le verrouillage d\'écran de votre appareil. Si ce verrouillage est faible, supprimé ou compromis, la protection offerte par PassKey est réduite d\'autant.\n\n5. PassKey intègre les services Google Firebase et Google Play décrits dans la politique de confidentialité.\n\n6. L\'application est fournie \"en l\'état\", sans garantie d\'aucune sorte, expresse ou implicite, y compris de qualité marchande et d\'adéquation à un usage particulier.\n\n7. Dans la mesure permise par la loi, le développeur n\'est pas responsable des dommages résultant de l\'utilisation de l\'application, y compris la perte de données.\n\n8. L\'application peut être modifiée ou abandonnée à tout moment. + Définir un mot de passe de secours + PassKey se déverrouille avec votre empreinte ou le verrouillage d\'écran. Ce mot de passe de secours est la voie de repli : il vous servira si vous changez vos empreintes ou supprimez le verrouillage. Il ne peut pas être réinitialisé, conservez-le en lieu sûr. + Cette mise à jour rechiffre votre coffre avec une clé propre à cet appareil. Définissez d\'abord un mot de passe de secours : c\'est le seul moyen de revenir si vous changez vos empreintes ou supprimez le verrouillage. Il ne peut pas être réinitialisé. + Saisissez votre mot de passe de secours + La biométrie ou le verrouillage d\'écran de votre appareil a changé, PassKey a besoin de votre mot de passe de secours. + Ce mot de passe n\'a pas fonctionné. Réessayez. + Sécurisation de votre coffre + Vos entrées sont rechiffrées avec une clé propre à cet appareil. Gardez l\'application ouverte. + Impossible de terminer la sécurisation + Rien n\'a été perdu et vos entrées sont intactes. Réessayez ou rouvrez l\'application plus tard. + Rien n\'a été perdu : votre coffre est intact et lisible. Réessayez, ou exportez d\'abord une sauvegarde depuis les paramètres. + Réessayer + Verrouillage de l\'appareil requis + PassKey protège votre coffre avec le verrouillage d\'écran. Configurez un code, un schéma, un mot de passe ou la biométrie pour continuer. + Ouvrir les paramètres diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index b2e33fe..6383e50 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -137,4 +137,19 @@ PassKey તમારા લૉગિન, પાસવર્ડ અને અન્ય ખાનગી નોંધો તમારા ઉપકરણ પરના એન્ક્રિપ્ટેડ ડેટાબેઝમાં સાચવે છે.\n\nતમારી વૉલ્ટ ક્યારેય તમારા ફોનની બહાર જતી નથી. કોઈ ખાતું નથી, સિંક નથી, PassKey નું કોઈ સર્વર પણ નથી: ઍપ પોતે કોઈ નેટવર્ક વિનંતી કરતી નથી અને તમારી નોંધો ક્યાંય મોકલાતી નથી.\n\nડેટાબેઝ SQLCipher થી એન્ક્રિપ્ટ થયેલો છે અને તમારા ઉપકરણના બાયોમેટ્રિક કે સ્ક્રીન લૉકથી ખૂલે છે. ઍપ છોડતાં જ વૉલ્ટ આપોઆપ ફરી લૉક થાય છે, સ્ક્રીનશૉટ રોકાયેલા છે, અને કૉપિ કરેલા પાસવર્ડ સંવેદનશીલ તરીકે ચિહ્નિત થઈને થોડી વારમાં ક્લિપબોર્ડમાંથી દૂર થાય છે.\n\nતમે નોંધોને શ્રેણીઓમાં ગોઠવી શકો, ખેંચીને ક્રમ બદલી શકો, મજબૂત પાસવર્ડ બનાવી શકો અને નબળા કે પુનરાવર્તિત પાસવર્ડ તપાસી શકો. બૅકઅપ એક જ ફાઇલ તરીકે નિકાસ થાય છે, તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટેડ, અને કોઈ પણ ઉપકરણ પર પુનઃસ્થાપિત થઈ શકે છે.\n\nPassKey મફત અને ઓપન સોર્સ છે, Aditya Bhardwaj દ્વારા વિકસાવાયેલ. સંપૂર્ણ સ્રોત કોડ GitHub પર ઉપલબ્ધ છે. તમારી વૉલ્ટ તમારા ઉપકરણ પર જ રહે છે.\n\nઅમે ક્યારેય શું એકત્ર કરતા નથી: PassKey તમે સાચવેલા પાસવર્ડ, વપરાશકર્તાનામ, નોંધો કે અન્ય કોઈ પણ નોંધ એકત્ર, પ્રસારિત કે સંગ્રહિત કરતું નથી. તે માત્ર તમારા ફોનના એન્ક્રિપ્ટેડ ડેટાબેઝમાં રહે છે. અમે તે વાંચી શકતા નથી અને તમારા માટે પાછી મેળવી પણ શકતા નથી.\n\nઍપ શું મોકલે છે: PassKey માં Google Firebase Crashlytics, Analytics અને Performance Monitoring તથા Google Play ઇન-ઍપ અપડેટ સામેલ છે. આ ક્રેશ નિદાન, વપરાશ ઘટનાઓ, કામગીરી માહિતી અને જાહેરાત ID સહિત ઉપકરણ તથા ઇન્સ્ટોલેશન ઓળખકર્તાઓ Google ને મોકલે છે. તેમને તમારી વૉલ્ટની સામગ્રી સુધી ક્યારેય પ્રવેશ મળતો નથી. આ સેવાઓને કારણે ઍપને ઇન્ટરનેટ પરવાનગી જોઈએ છે, અને તેમનું ડેટા સંચાલન Google ની ગોપનીયતા નીતિ હેઠળ આવે છે.\n\nબૅકઅપ: નિકાસ વખતે ફાઇલ તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટ થાય છે અને તમે સિસ્ટમને જ્યાં કહો ત્યાં સચવાય છે. ત્યાર બાદ તે ફાઇલની જવાબદારી તમારી છે; જેની પાસે ફાઇલ અને પાસવર્ડ બંને હોય તે તેને વાંચી શકે છે. અમે કોઈ નકલ રાખતા નથી.\n\nઉપકરણ બૅકઅપ: PassKey ને Android ક્લાઉડ બૅકઅપ અને ઉપકરણ-થી-ઉપકરણ સ્થાનાંતરણમાંથી બાકાત રાખ્યું છે, તેથી તમારી વૉલ્ટ તમારા Google ખાતામાં અપલોડ થતી નથી.\n\nફેરફારો: આ નીતિ અપડેટ થઈ શકે છે; મહત્ત્વના ફેરફારો ઍપની રિલીઝ નોંધમાં જણાવાશે.\n\nપ્રશ્નો: સંપર્ક yrkkh.cclub@gmail.com. PassKey વાપરીને તમે નીચેની બાબતો સાથે સંમત થાઓ છો.\n\n1. PassKey એ Aditya Bhardwaj દ્વારા વિકસાવાયેલ મફત, ઓપન-સોર્સ સોફ્ટવેર છે.\n\n2. તમારી વૉલ્ટ માત્ર તમારા ઉપકરણ પર સંગ્રહિત છે. સર્વર પર કોઈ નકલ નથી, તેથી ઉપકરણ ખોવાય, ઍપ દૂર કરો કે બૅકઅપ પાસવર્ડ ભૂલી જાઓ તો ડેટા પાછો મેળવવાનો કોઈ માર્ગ નથી.\n\n3. બૅકઅપ રાખવાની જવાબદારી તમારી છે. નિયમિત નિકાસ કરો અને બૅકઅપ પાસવર્ડ સુરક્ષિત, અલગ જગ્યાએ રાખો.\n\n4. ઍપ તમારા ઉપકરણની બાયોમેટ્રિક કે સ્ક્રીન-લૉક સુરક્ષા પર આધારિત છે. જો તે લૉક નબળો, દૂર કરેલો કે ભંગ થયેલો હોય, તો PassKey નું રક્ષણ તે પ્રમાણમાં ઘટે છે.\n\n5. PassKey માં ગોપનીયતા નીતિમાં વર્ણવેલ Google Firebase અને Google Play સેવાઓ સામેલ છે.\n\n6. આ એપ્લિકેશન \"જેમ છે તેમ\" પૂરી પાડવામાં આવે છે, કોઈ પણ સ્પષ્ટ કે ગર્ભિત વોરંટી વિના, જેમાં વેપારયોગ્યતા અને ચોક્કસ હેતુ માટેની યોગ્યતા સામેલ છે.\n\n7. કાયદા દ્વારા માન્ય હદ સુધી, ડેવલપર એપ્લિકેશનના ઉપયોગથી થતા કોઈ પણ નુકસાન માટે, ડેટા ગુમાવવા સહિત, જવાબદાર નથી.\n\n8. એપ્લિકેશન કોઈ પણ સમયે સુધારી કે બંધ કરી શકાય છે. + પુનઃપ્રાપ્તિ પાસવર્ડ સેટ કરો + PassKey તમારી ફિંગરપ્રિન્ટ કે સ્ક્રીન લૉકથી ખૂલે છે. આ પુનઃપ્રાપ્તિ પાસવર્ડ વૈકલ્પિક રસ્તો છે — ફિંગરપ્રિન્ટ બદલો કે ઉપકરણ લૉક દૂર કરો ત્યારે જરૂર પડશે. તે રીસેટ થઈ શકતો નથી, સુરક્ષિત જગ્યાએ રાખો. + આ અપડેટ તમારી વૉલ્ટને આ ઉપકરણ માટેની અનન્ય કી વડે ફરીથી એન્ક્રિપ્ટ કરે છે. પહેલાં પુનઃપ્રાપ્તિ પાસવર્ડ સેટ કરો: ફિંગરપ્રિન્ટ બદલો કે લૉક દૂર કરો ત્યારે એ જ એકમાત્ર રસ્તો છે. તે રીસેટ થઈ શકતો નથી. + તમારો પુનઃપ્રાપ્તિ પાસવર્ડ દાખલ કરો + તમારા ઉપકરણના બાયોમેટ્રિક કે સ્ક્રીન લૉક બદલાયા છે, તેથી વૉલ્ટ ખોલવા PassKey ને પુનઃપ્રાપ્તિ પાસવર્ડ જોઈએ. + એ પાસવર્ડ કામ ન આવ્યો. ફરી પ્રયાસ કરો. + તમારી વૉલ્ટ સુરક્ષિત કરી રહ્યા છીએ + તમારી નોંધો આ ઉપકરણની અનન્ય કી વડે ફરીથી એન્ક્રિપ્ટ થઈ રહી છે. ઍપ ખુલ્લી રાખો. + વૉલ્ટ સુરક્ષિત કરવાનું પૂરું ન થઈ શક્યું + કંઈ ખોવાયું નથી અને તમારી નોંધો અકબંધ છે. ફરી પ્રયાસ કરો, અથવા પછી ઍપ ખોલો. + કંઈ ખોવાયું નથી — તમારી વૉલ્ટ અકબંધ અને વાંચી શકાય તેવી છે. ફરી પ્રયાસ કરો, અથવા પહેલાં સેટિંગ્સમાંથી બૅકઅપ લો. + ફરી પ્રયાસ કરો + ઉપકરણ લૉક જરૂરી + PassKey તમારી વૉલ્ટને ઉપકરણના સ્ક્રીન લૉકથી સુરક્ષિત રાખે છે. ચાલુ રાખવા પિન, પૅટર્ન, પાસવર્ડ કે બાયોમેટ્રિક સેટ કરો. + સેટિંગ્સ ખોલો diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 94bb73a..ae8560c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -138,4 +138,19 @@ PassKey आपके लॉगिन, पासवर्ड और अन्य निजी नोट्स को आपके डिवाइस पर एक एन्क्रिप्टेड डेटाबेस में सहेजता है।\n\nआपकी वॉल्ट कभी आपके फ़ोन से बाहर नहीं जाती। न कोई खाता है, न सिंक, न PassKey का कोई सर्वर: ऐप अपनी ओर से कोई नेटवर्क अनुरोध नहीं करता और आपकी प्रविष्टियाँ कहीं नहीं भेजी जातीं।\n\nडेटाबेस SQLCipher से एन्क्रिप्ट किया गया है और आपके डिवाइस के बायोमेट्रिक या स्क्रीन लॉक से खुलता है। ऐप छोड़ते ही वॉल्ट अपने आप फिर लॉक हो जाती है, स्क्रीनशॉट रोक दिए गए हैं, और कॉपी किए गए पासवर्ड संवेदनशील चिह्नित होकर कुछ ही देर में क्लिपबोर्ड से हट जाते हैं।\n\nआप प्रविष्टियों को श्रेणियों में रख सकते हैं, खींचकर क्रम बदल सकते हैं, मज़बूत पासवर्ड बना सकते हैं और कमज़ोर या दोहराए गए पासवर्ड जाँच सकते हैं। बैकअप एक ही फ़ाइल के रूप में निर्यात होता है, जो आपके चुने पासवर्ड से एन्क्रिप्टेड होता है और किसी भी डिवाइस पर बहाल किया जा सकता है।\n\nPassKey निःशुल्क और ओपन सोर्स है, जिसे Aditya Bhardwaj ने बनाया है। पूरा स्रोत कोड GitHub पर उपलब्ध है। आपकी वॉल्ट आपके डिवाइस पर ही रहती है।\n\nहम कभी क्या एकत्र नहीं करते: PassKey आपके सहेजे गए पासवर्ड, उपयोगकर्ता नाम, नोट्स या किसी भी प्रविष्टि को एकत्र, प्रेषित या संग्रहीत नहीं करता। ये केवल आपके फ़ोन के एन्क्रिप्टेड डेटाबेस में रहते हैं। हम न इन्हें पढ़ सकते हैं, न आपके लिए वापस ला सकते हैं।\n\nऐप क्या भेजता है: PassKey में Google Firebase Crashlytics, Analytics और Performance Monitoring तथा Google Play इन-ऐप अपडेट शामिल हैं। ये क्रैश डायग्नोस्टिक्स, उपयोग इवेंट, प्रदर्शन डेटा और विज्ञापन आईडी सहित डिवाइस व इंस्टॉलेशन पहचानकर्ता Google को भेजते हैं। इनकी पहुँच आपकी वॉल्ट की सामग्री तक कभी नहीं होती। इन सेवाओं के कारण ऐप को इंटरनेट अनुमति चाहिए, और इनका डेटा प्रबंधन Google की गोपनीयता नीति से संचालित होता है।\n\nबैकअप: निर्यात करते समय फ़ाइल आपके चुने पासवर्ड से एन्क्रिप्ट होती है और वहीं सहेजी जाती है जहाँ आप सिस्टम को बताते हैं। उसके बाद उस फ़ाइल की ज़िम्मेदारी आपकी है; जिसके पास फ़ाइल और पासवर्ड दोनों हों वह उसे पढ़ सकता है। हम कोई प्रति नहीं रखते।\n\nडिवाइस बैकअप: PassKey को Android क्लाउड बैकअप और डिवाइस-टू-डिवाइस ट्रांसफ़र से बाहर रखा गया है, इसलिए आपकी वॉल्ट आपके Google खाते में अपलोड नहीं होती।\n\nबदलाव: यह नीति अद्यतन हो सकती है; महत्वपूर्ण बदलाव ऐप के रिलीज़ नोट्स में बताए जाएँगे।\n\nप्रश्न: संपर्क करें yrkkh.cclub@gmail.com। PassKey का उपयोग करके आप निम्नलिखित से सहमत होते हैं।\n\n1. PassKey एक निःशुल्क, ओपन-सोर्स सॉफ़्टवेयर है जिसे Aditya Bhardwaj ने विकसित किया है।\n\n2. आपकी वॉल्ट केवल आपके डिवाइस पर संग्रहीत होती है। सर्वर पर कोई प्रति नहीं है, इसलिए डिवाइस खोने, ऐप हटाने या बैकअप पासवर्ड भूलने पर डेटा वापस पाने का कोई उपाय नहीं है।\n\n3. बैकअप रखना आपकी ज़िम्मेदारी है। इन्हें नियमित रूप से निर्यात करें और बैकअप पासवर्ड को सुरक्षित, अलग जगह रखें।\n\n4. ऐप आपके डिवाइस की बायोमेट्रिक या स्क्रीन-लॉक सुरक्षा पर निर्भर करता है। यदि वह लॉक कमज़ोर, हटाया हुआ या असुरक्षित है, तो PassKey द्वारा दी गई सुरक्षा उसी अनुपात में घट जाती है।\n\n5. PassKey में गोपनीयता नीति में वर्णित Google Firebase और Google Play सेवाएँ शामिल हैं।\n\n6. यह एप्लिकेशन \"जैसा है\" प्रदान किया जाता है, बिना किसी व्यक्त या निहित वारंटी के, जिसमें व्यापारिकता और किसी विशेष उद्देश्य के लिए उपयुक्तता शामिल है।\n\n7. कानून द्वारा अनुमत सीमा तक, डेवलपर एप्लिकेशन के उपयोग से उत्पन्न किसी भी क्षति, जिसमें डेटा हानि शामिल है, के लिए उत्तरदायी नहीं है।\n\n8. एप्लिकेशन को किसी भी समय संशोधित या बंद किया जा सकता है। + पुनर्प्राप्ति पासवर्ड सेट करें + PassKey आपके फ़िंगरप्रिंट या स्क्रीन लॉक से खुलता है। यह पुनर्प्राप्ति पासवर्ड अंदर जाने का वैकल्पिक रास्ता है — फ़िंगरप्रिंट बदलने या डिवाइस लॉक हटाने पर यही काम आएगा। इसे रीसेट नहीं किया जा सकता, इसलिए सुरक्षित जगह रखें। + यह अपडेट आपकी वॉल्ट को इस डिवाइस के लिए अनोखी कुंजी से दोबारा एन्क्रिप्ट करता है। पहले पुनर्प्राप्ति पासवर्ड सेट करें: फ़िंगरप्रिंट बदलने या डिवाइस लॉक हटाने पर यही एकमात्र रास्ता होगा। इसे रीसेट नहीं किया जा सकता, इसलिए सुरक्षित जगह रखें। + अपना पुनर्प्राप्ति पासवर्ड डालें + आपके डिवाइस के बायोमेट्रिक या स्क्रीन लॉक बदल गए हैं, इसलिए वॉल्ट खोलने के लिए PassKey को आपका पुनर्प्राप्ति पासवर्ड चाहिए। + यह पासवर्ड काम नहीं आया। फिर कोशिश करें। + आपकी वॉल्ट सुरक्षित की जा रही है + आपकी प्रविष्टियों को इस डिवाइस की अनोखी कुंजी से दोबारा एन्क्रिप्ट किया जा रहा है। कृपया ऐप खुला रखें। + वॉल्ट सुरक्षित करना पूरा नहीं हो सका + कुछ भी नहीं खोया और आपकी प्रविष्टियाँ जस की तस हैं। फिर कोशिश करें, या बाद में ऐप दोबारा खोलें। + कुछ भी नहीं खोया — आपकी वॉल्ट सुरक्षित और पढ़ने योग्य है। फिर कोशिश करें, या पहले सेटिंग्स से बैकअप निर्यात करें। + फिर कोशिश करें + डिवाइस लॉक ज़रूरी है + PassKey आपकी वॉल्ट को डिवाइस के स्क्रीन लॉक से सुरक्षित रखता है। जारी रखने के लिए पिन, पैटर्न, पासवर्ड या बायोमेट्रिक सेट करें। + सेटिंग्स खोलें \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 48b2a51..43bdc77 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -139,4 +139,19 @@ PassKey salva credenziali, password e altre note private in un database cifrato sul tuo dispositivo.\n\nLa tua cassaforte non lascia mai il telefono. Non c\'è alcun account, nessuna sincronizzazione e nessun server PassKey: l\'app non effettua richieste di rete proprie e le tue voci non vengono trasmesse da nessuna parte.\n\nIl database è cifrato con SQLCipher e si sblocca con la biometria o il blocco schermo del dispositivo. La cassaforte si riblocca automaticamente quando esci dall\'app, gli screenshot sono bloccati e le password copiate vengono contrassegnate come sensibili ed eliminate dagli appunti poco dopo.\n\nPuoi organizzare le voci in categorie, riordinarle trascinando, generare password robuste e controllare la cassaforte alla ricerca di password deboli o riutilizzate. I backup vengono esportati come un unico file cifrato con una password scelta da te e si possono ripristinare su qualsiasi dispositivo.\n\nPassKey è gratuita e open source, sviluppata da Aditya Bhardwaj. Il codice sorgente completo è disponibile su GitHub. La tua cassaforte resta sul tuo dispositivo.\n\nCosa non raccogliamo mai: PassKey non raccoglie, trasmette né memorizza le password, i nomi utente, le note o qualsiasi altra voce che salvi. Esistono soltanto in un database cifrato sul tuo telefono. Non possiamo leggerle né recuperarle per te.\n\nCosa invia l\'app: PassKey include Google Firebase Crashlytics, Analytics e Performance Monitoring e gli aggiornamenti in-app di Google Play. Questi servizi inviano a Google diagnostiche di arresto anomalo, eventi di utilizzo, tracce prestazionali e identificatori di dispositivo e installazione, compreso un ID pubblicitario. Non hanno mai accesso al contenuto della cassaforte. A causa di questi servizi l\'app richiede l\'autorizzazione a Internet, e il loro trattamento dei dati è regolato dall\'informativa privacy di Google.\n\nBackup: quando esporti un backup, il file viene cifrato con una password scelta da te e salvato dove indichi al sistema. Quel file è poi sotto la tua responsabilità; chiunque ottenga sia il file sia la password può leggerlo. Non ne conserviamo copia.\n\nBackup del dispositivo: PassKey è esclusa dal backup cloud di Android e dal trasferimento tra dispositivi, quindi la cassaforte non viene caricata sul tuo account Google.\n\nModifiche: questa informativa può essere aggiornata; le modifiche rilevanti saranno indicate nelle note di versione.\n\nDomande: scrivi a yrkkh.cclub@gmail.com. Usando PassKey accetti quanto segue.\n\n1. PassKey è software gratuito e open source sviluppato da Aditya Bhardwaj.\n\n2. La tua cassaforte è archiviata solo sul tuo dispositivo. Non esiste copia lato server e quindi non c\'è modo di recuperare i dati se perdi il dispositivo, disinstalli l\'app o dimentichi una password di backup.\n\n3. Conservare i backup è una tua responsabilità. Esportali regolarmente e custodisci la password del backup in un luogo sicuro e separato.\n\n4. L\'app si appoggia alla sicurezza biometrica o al blocco schermo del dispositivo. Se tale blocco è debole, rimosso o compromesso, la protezione offerta da PassKey si riduce di conseguenza.\n\n5. PassKey include i servizi Google Firebase e Google Play descritti nell\'Informativa sulla privacy.\n\n6. L\'applicazione è fornita \"così com\'è\", senza garanzie di alcun tipo, esplicite o implicite, comprese quelle di commerciabilità e idoneità a uno scopo particolare.\n\n7. Nei limiti consentiti dalla legge, lo sviluppatore non è responsabile per danni derivanti dall\'uso dell\'applicazione, inclusa la perdita di dati.\n\n8. L\'applicazione può essere modificata o dismessa in qualsiasi momento. + Imposta una password di recupero + PassKey si sblocca con l\'impronta o il blocco schermo. Questa password di recupero è la via alternativa: ti servirà se cambi le impronte o rimuovi il blocco del dispositivo. Non può essere reimpostata, conservala al sicuro. + Questo aggiornamento ricifra la cassaforte con una chiave unica per questo dispositivo. Imposta prima una password di recupero: è l\'unico modo per rientrare se cambi le impronte o rimuovi il blocco. Non può essere reimpostata. + Inserisci la password di recupero + La biometria o il blocco schermo del dispositivo sono cambiati, quindi PassKey ha bisogno della password di recupero. + Quella password non ha funzionato. Riprova. + Messa in sicurezza della cassaforte + Le tue voci vengono ricifrate con una chiave unica per questo dispositivo. Tieni l\'app aperta. + Impossibile completare la messa in sicurezza + Non è andato perso nulla e le tue voci sono intatte. Riprova o riapri l\'app più tardi. + Non è andato perso nulla: la cassaforte è intatta e leggibile. Riprova, oppure esporta prima un backup dalle impostazioni. + Riprova + Blocco del dispositivo necessario + PassKey protegge la cassaforte con il blocco schermo del dispositivo. Imposta PIN, sequenza, password o biometria per continuare. + Apri impostazioni diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9808cb5..1950b76 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -136,4 +136,19 @@ PassKey は、ログイン情報・パスワード・その他の非公開メモを、お使いの端末上の暗号化データベースに保存します。\n\n保管庫が端末外に出ることはありません。アカウントも同期も PassKey のサーバーも存在せず、アプリ自身はネットワーク通信を行わないため、登録内容がどこかへ送信されることはありません。\n\nデータベースは SQLCipher で暗号化され、端末の生体認証または画面ロックで解錠します。アプリを離れると保管庫は自動的に再ロックされ、スクリーンショットは禁止され、コピーしたパスワードは機密として扱われ、まもなくクリップボードから消去されます。\n\n項目はカテゴリ分けやドラッグによる並べ替えができ、強力なパスワードの生成や、脆弱・使い回しパスワードの点検も行えます。バックアップは、ご自身で決めたパスワードで暗号化した単一のファイルとして書き出され、どの端末でも復元できます。\n\nPassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアで、ソースコードはすべて GitHub で公開されています。 保管庫は端末内に留まります。\n\n収集しないもの: PassKey は、保存されたパスワード・ユーザー名・メモなどの項目を一切収集・送信・保管しません。それらは端末上の暗号化データベースにのみ存在します。当方が内容を読むことも、復元することもできません。\n\nアプリが送信するもの: PassKey には Google Firebase Crashlytics、Analytics、Performance Monitoring、および Google Play のアプリ内更新が含まれます。これらはクラッシュ診断、利用イベント、パフォーマンス情報、広告 ID を含む端末およびインストール識別子を Google に送信します。保管庫の中身にアクセスすることはありません。これらのサービスのため、アプリはインターネット権限を必要とし、そのデータの取り扱いは Google のプライバシーポリシーに従います。\n\nバックアップ: 書き出したファイルはご自身で決めたパスワードで暗号化され、システムで指定した場所に保存されます。以後そのファイルの管理はご自身の責任となり、ファイルとパスワードの双方を入手した者は内容を読むことができます。当方は控えを保持しません。\n\n端末バックアップ: PassKey は Android のクラウドバックアップおよび端末間転送の対象外です。保管庫が Google アカウントへアップロードされることはありません。\n\n変更: 本ポリシーは更新される場合があります。重要な変更はリリースノートに記載します。\n\nお問い合わせ: yrkkh.cclub@gmail.com までご連絡ください。 PassKey を利用することで、以下に同意したものとみなされます。\n\n1. PassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアです。\n\n2. 保管庫は端末上にのみ保存されます。サーバー側の控えは存在しないため、端末の紛失、アプリの削除、バックアップパスワードの失念があった場合にデータを復元する手段はありません。\n\n3. バックアップの保持は利用者の責任です。定期的に書き出し、バックアップパスワードは安全な別の場所に保管してください。\n\n4. 本アプリは端末の生体認証または画面ロックに依存します。そのロックが脆弱、解除済み、または侵害されている場合、PassKey による保護もその分低下します。\n\n5. PassKey にはプライバシーポリシーに記載の Google Firebase および Google Play のサービスが含まれます。\n\n6. 本アプリケーションは「現状有姿」で提供され、商品性および特定目的への適合性を含め、明示黙示を問わずいかなる保証も行いません。\n\n7. 法令が許す範囲において、開発者はデータの喪失を含む本アプリケーションの利用に起因する損害について責任を負いません。\n\n8. 本アプリケーションは予告なく変更または提供終了となる場合があります。 + 復旧用パスワードを設定 + PassKey は指紋または画面ロックで解錠します。この復旧用パスワードは代替の入口です。指紋を変更したり端末ロックを解除した場合に必要になります。再発行はできませんので安全な場所に保管してください。 + このアップデートでは、保管庫をこの端末固有の鍵で暗号化し直します。まず復旧用パスワードを設定してください。指紋を変更したり端末ロックを解除した場合、これが唯一の入口になります。再発行はできません。 + 復旧用パスワードを入力 + 端末の生体認証または画面ロックが変更されたため、保管庫を開くには復旧用パスワードが必要です。 + そのパスワードでは解錠できませんでした。もう一度お試しください。 + 保管庫を保護しています + この端末固有の鍵で登録内容を暗号化し直しています。アプリを開いたままにしてください。 + 保管庫の保護を完了できませんでした + 失われたものはなく、登録内容はそのままです。もう一度試すか、後でアプリを開いてください。 + 失われたものはありません。保管庫は無傷で読み取り可能です。もう一度試すか、先に設定からバックアップを書き出してください。 + 再試行 + 端末ロックが必要です + PassKey は端末の画面ロックで保管庫を保護します。続けるには PIN、パターン、パスワード、または生体認証を設定してください。 + 設定を開く \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 522cd50..2d3fb9f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -137,4 +137,19 @@ PassKey는 로그인 정보, 비밀번호, 그 밖의 비공개 메모를 기기 안의 암호화된 데이터베이스에 저장합니다.\n\n보관함은 기기를 벗어나지 않습니다. 계정도, 동기화도, PassKey 서버도 없습니다. 앱 자체는 네트워크 요청을 하지 않으며 저장한 항목이 어디로도 전송되지 않습니다.\n\n데이터베이스는 SQLCipher로 암호화되며 기기의 생체 인증이나 화면 잠금으로 해제합니다. 앱을 벗어나면 보관함이 자동으로 다시 잠기고, 스크린샷은 차단되며, 복사한 비밀번호는 민감 정보로 표시된 뒤 잠시 후 클립보드에서 지워집니다.\n\n항목을 카테고리로 정리하고 끌어서 순서를 바꿀 수 있으며, 강력한 비밀번호를 생성하고 취약하거나 재사용된 비밀번호를 점검할 수 있습니다. 백업은 직접 정한 비밀번호로 암호화된 단일 파일로 내보내지며 어떤 기기에서도 복원할 수 있습니다.\n\nPassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어이며 전체 소스 코드는 GitHub에서 공개되어 있습니다. 보관함은 기기에 남아 있습니다.\n\n수집하지 않는 것: PassKey는 저장한 비밀번호, 사용자 이름, 메모 등 어떤 항목도 수집하거나 전송하거나 보관하지 않습니다. 해당 정보는 기기의 암호화된 데이터베이스에만 존재합니다. 저희는 이를 읽을 수도, 대신 복구해 드릴 수도 없습니다.\n\n앱이 전송하는 것: PassKey에는 Google Firebase Crashlytics, Analytics, Performance Monitoring과 Google Play 인앱 업데이트가 포함됩니다. 이들은 비정상 종료 진단, 사용 이벤트, 성능 기록, 광고 ID를 포함한 기기 및 설치 식별자를 Google로 전송합니다. 보관함 내용에는 접근하지 않습니다. 이러한 서비스 때문에 앱은 인터넷 권한을 필요로 하며, 해당 데이터 처리는 Google의 개인정보처리방침을 따릅니다.\n\n백업: 백업을 내보내면 직접 정한 비밀번호로 파일이 암호화되어 지정한 위치에 저장됩니다. 이후 그 파일은 사용자의 책임이며, 파일과 비밀번호를 모두 가진 사람은 내용을 읽을 수 있습니다. 저희는 사본을 보관하지 않습니다.\n\n기기 백업: PassKey는 Android 클라우드 백업과 기기 간 전송에서 제외되어 보관함이 Google 계정에 업로드되지 않습니다.\n\n변경: 본 방침은 변경될 수 있으며, 중요한 변경은 앱 출시 노트에 기재됩니다.\n\n문의: yrkkh.cclub@gmail.com 로 연락해 주세요. PassKey를 사용함으로써 다음에 동의하게 됩니다.\n\n1. PassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어입니다.\n\n2. 보관함은 기기에만 저장됩니다. 서버 사본이 없으므로 기기를 분실하거나 앱을 삭제하거나 백업 비밀번호를 잊은 경우 데이터를 복구할 방법이 없습니다.\n\n3. 백업 보관은 사용자의 책임입니다. 정기적으로 내보내고 백업 비밀번호는 안전한 별도 장소에 보관하세요.\n\n4. 앱은 기기의 생체 인증 또는 화면 잠금 보안에 의존합니다. 해당 잠금이 취약하거나 해제되거나 침해되면 PassKey가 제공하는 보호도 그만큼 약해집니다.\n\n5. PassKey에는 개인정보처리방침에 기술된 Google Firebase 및 Google Play 서비스가 포함됩니다.\n\n6. 본 애플리케이션은 상품성 및 특정 목적 적합성을 포함하여 명시적이든 묵시적이든 어떠한 보증도 없이 \"있는 그대로\" 제공됩니다.\n\n7. 법이 허용하는 범위에서 개발자는 데이터 손실을 포함해 애플리케이션 사용으로 발생한 손해에 대해 책임지지 않습니다.\n\n8. 애플리케이션은 언제든 변경되거나 중단될 수 있습니다. + 복구 비밀번호 설정 + PassKey는 지문이나 화면 잠금으로 열립니다. 이 복구 비밀번호는 대체 수단으로, 지문을 변경하거나 기기 잠금을 해제하면 필요합니다. 재설정할 수 없으니 안전한 곳에 보관하세요. + 이 업데이트는 보관함을 이 기기 전용 키로 다시 암호화합니다. 먼저 복구 비밀번호를 설정하세요. 지문을 변경하거나 기기 잠금을 없앴을 때 유일한 방법입니다. 재설정할 수 없습니다. + 복구 비밀번호 입력 + 기기의 생체 인증이나 화면 잠금이 변경되어 보관함을 열려면 복구 비밀번호가 필요합니다. + 해당 비밀번호로는 열 수 없습니다. 다시 시도하세요. + 보관함을 보호하는 중 + 이 기기 전용 키로 항목을 다시 암호화하고 있습니다. 앱을 열어 두세요. + 보관함 보호를 마치지 못했습니다 + 잃어버린 것은 없으며 항목은 그대로입니다. 다시 시도하거나 나중에 앱을 열어 주세요. + 잃어버린 것은 없습니다. 보관함은 온전하며 읽을 수 있습니다. 다시 시도하거나 설정에서 먼저 백업을 내보내세요. + 다시 시도 + 기기 잠금 필요 + PassKey는 기기의 화면 잠금으로 보관함을 보호합니다. 계속하려면 PIN, 패턴, 비밀번호 또는 생체 인증을 설정하세요. + 설정 열기 \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 1ebde8c..a283bc4 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -138,4 +138,19 @@ PassKey तुमचे लॉगिन, पासवर्ड आणि इतर खाजगी नोंदी तुमच्या उपकरणावरील एन्क्रिप्टेड डेटाबेसमध्ये साठवते.\n\nतुमची व्हॉल्ट कधीही फोनबाहेर जात नाही. खाते नाही, सिंक नाही, PassKey चा सर्व्हरही नाही: ॲप स्वतःहून कोणतीही नेटवर्क विनंती करत नाही आणि तुमच्या नोंदी कुठेही पाठवल्या जात नाहीत.\n\nडेटाबेस SQLCipher ने एन्क्रिप्ट केलेला असून तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन लॉकने उघडतो. ॲप सोडताच व्हॉल्ट आपोआप पुन्हा लॉक होते, स्क्रीनशॉट रोखलेले आहेत, आणि कॉपी केलेले पासवर्ड संवेदनशील म्हणून चिन्हांकित होऊन थोड्याच वेळात क्लिपबोर्डमधून हटवले जातात.\n\nनोंदी श्रेणींमध्ये मांडता येतात, ओढून क्रम बदलता येतो, मजबूत पासवर्ड तयार करता येतात आणि कमकुवत किंवा पुन्हा वापरलेले पासवर्ड तपासता येतात. बॅकअप एकाच फाइलमध्ये निर्यात होतो, तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट केलेला, आणि कोणत्याही उपकरणावर पुनर्संचयित करता येतो.\n\nPassKey विनामूल्य आणि मुक्तस्रोत आहे, Aditya Bhardwaj यांनी विकसित केलेले. संपूर्ण स्रोत कोड GitHub वर उपलब्ध आहे. तुमची व्हॉल्ट तुमच्या उपकरणावरच राहते.\n\nआम्ही कधीही काय गोळा करत नाही: PassKey तुम्ही जतन केलेले पासवर्ड, वापरकर्तानावे, नोंदी किंवा इतर कोणतीही माहिती गोळा, प्रसारित किंवा साठवत नाही. ती फक्त तुमच्या फोनवरील एन्क्रिप्टेड डेटाबेसमध्ये असते. आम्ही ती वाचू शकत नाही आणि तुमच्यासाठी परत मिळवूही शकत नाही.\n\nॲप काय पाठवते: PassKey मध्ये Google Firebase Crashlytics, Analytics आणि Performance Monitoring, तसेच Google Play इन-ॲप अपडेट समाविष्ट आहेत. हे क्रॅश निदान, वापर घटना, कार्यक्षमता माहिती आणि जाहिरात आयडीसह उपकरण व स्थापना ओळखचिन्हे Google ला पाठवतात. त्यांना तुमच्या व्हॉल्टच्या मजकुरापर्यंत कधीही प्रवेश नसतो. या सेवांमुळे ॲपला इंटरनेट परवानगी लागते आणि त्यांची माहिती हाताळणी Google च्या गोपनीयता धोरणानुसार होते.\n\nबॅकअप: निर्यात करताना फाइल तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट होते आणि तुम्ही सिस्टमला सांगाल तिथे साठवली जाते. त्यानंतर ती फाइल तुमची जबाबदारी आहे; ज्याच्याकडे फाइल आणि पासवर्ड दोन्ही असेल तो ती वाचू शकतो. आम्ही प्रत ठेवत नाही.\n\nउपकरण बॅकअप: PassKey ला Android क्लाउड बॅकअप आणि उपकरण-ते-उपकरण हस्तांतरणातून वगळले आहे, त्यामुळे तुमची व्हॉल्ट तुमच्या Google खात्यावर अपलोड होत नाही.\n\nबदल: हे धोरण अद्ययावत होऊ शकते; महत्त्वाचे बदल ॲपच्या रिलीज नोट्समध्ये नमूद केले जातील.\n\nप्रश्न: संपर्क yrkkh.cclub@gmail.com. PassKey वापरून तुम्ही पुढील बाबींना सहमती देता.\n\n1. PassKey हे Aditya Bhardwaj यांनी विकसित केलेले विनामूल्य, मुक्तस्रोत सॉफ्टवेअर आहे.\n\n2. तुमची व्हॉल्ट फक्त तुमच्या उपकरणावर साठवली जाते. सर्व्हरवर प्रत नाही, त्यामुळे उपकरण हरवल्यास, ॲप काढून टाकल्यास किंवा बॅकअप पासवर्ड विसरल्यास डेटा परत मिळवण्याचा मार्ग नाही.\n\n3. बॅकअप ठेवणे ही तुमची जबाबदारी आहे. ते नियमितपणे निर्यात करा आणि बॅकअप पासवर्ड सुरक्षित, वेगळ्या ठिकाणी ठेवा.\n\n4. ॲप तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन-लॉक सुरक्षेवर अवलंबून आहे. ते लॉक कमकुवत, काढलेले किंवा तडजोड झालेले असल्यास PassKey देत असलेले संरक्षण त्या प्रमाणात कमी होते.\n\n5. PassKey मध्ये गोपनीयता धोरणात वर्णन केलेल्या Google Firebase आणि Google Play सेवा समाविष्ट आहेत.\n\n6. हे ॲप्लिकेशन \"जसे आहे तसे\" पुरवले जाते, कोणत्याही स्पष्ट किंवा गर्भित हमीशिवाय, ज्यात व्यापारयोग्यता आणि विशिष्ट हेतूसाठी उपयुक्तता समाविष्ट आहे.\n\n7. कायद्याने परवानगी दिलेल्या मर्यादेत, ॲप्लिकेशनच्या वापरामुळे होणाऱ्या कोणत्याही नुकसानीस, डेटा हानीसह, विकसक जबाबदार नाही.\n\n8. ॲप्लिकेशन कधीही सुधारले किंवा बंद केले जाऊ शकते. + पुनर्प्राप्ती पासवर्ड सेट करा + PassKey तुमच्या बोटांचा ठसा किंवा स्क्रीन लॉकने उघडते. हा पुनर्प्राप्ती पासवर्ड पर्यायी मार्ग आहे — ठसे बदलल्यास किंवा उपकरण लॉक काढल्यास तो लागेल. तो रीसेट करता येत नाही, म्हणून सुरक्षित ठिकाणी ठेवा. + हे अपडेट तुमची व्हॉल्ट या उपकरणासाठी खास किल्लीने पुन्हा एन्क्रिप्ट करते. आधी पुनर्प्राप्ती पासवर्ड सेट करा: ठसे बदलल्यास किंवा लॉक काढल्यास तोच एकमेव मार्ग असेल. तो रीसेट करता येत नाही. + तुमचा पुनर्प्राप्ती पासवर्ड टाका + तुमच्या उपकरणाचे बायोमेट्रिक किंवा स्क्रीन लॉक बदलले आहे, त्यामुळे व्हॉल्ट उघडण्यासाठी PassKey ला पुनर्प्राप्ती पासवर्ड हवा. + तो पासवर्ड चालला नाही. पुन्हा प्रयत्न करा. + तुमची व्हॉल्ट सुरक्षित करत आहोत + तुमच्या नोंदी या उपकरणाच्या खास किल्लीने पुन्हा एन्क्रिप्ट होत आहेत. कृपया ॲप उघडे ठेवा. + व्हॉल्ट सुरक्षित करणे पूर्ण झाले नाही + काहीही हरवले नाही आणि तुमच्या नोंदी जशाच्या तशा आहेत. पुन्हा प्रयत्न करा, किंवा नंतर ॲप उघडा. + काहीही हरवले नाही — तुमची व्हॉल्ट सुरक्षित आणि वाचनीय आहे. पुन्हा प्रयत्न करा, किंवा आधी सेटिंग्जमधून बॅकअप घ्या. + पुन्हा प्रयत्न करा + उपकरण लॉक आवश्यक + PassKey तुमची व्हॉल्ट उपकरणाच्या स्क्रीन लॉकने सुरक्षित ठेवते. पुढे जाण्यासाठी पिन, पॅटर्न, पासवर्ड किंवा बायोमेट्रिक सेट करा. + सेटिंग्ज उघडा \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2df2e3a..f5e2b51 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -139,4 +139,19 @@ O PassKey guarda seus logins, senhas e outras notas privadas em um banco de dados criptografado no seu dispositivo.\n\nSeu cofre nunca sai do celular. Não há conta, sincronização nem servidor do PassKey: o aplicativo não faz requisições de rede próprias e suas entradas nunca são transmitidas para lugar algum.\n\nO banco de dados é criptografado com SQLCipher e desbloqueado com a biometria ou o bloqueio de tela do aparelho. O cofre volta a bloquear automaticamente quando você sai do app, capturas de tela são bloqueadas e senhas copiadas são marcadas como sensíveis e apagadas da área de transferência logo depois.\n\nVocê pode organizar entradas em categorias, reordená-las arrastando, gerar senhas fortes e verificar o cofre em busca de senhas fracas ou repetidas. Os backups são exportados como um único arquivo criptografado com uma senha escolhida por você e podem ser restaurados em qualquer dispositivo.\n\nO PassKey é gratuito e de código aberto, desenvolvido por Aditya Bhardwaj. O código-fonte completo está disponível no GitHub. Seu cofre permanece no seu dispositivo.\n\nO que nunca coletamos: o PassKey não coleta, transmite nem armazena as senhas, nomes de usuário, notas ou quaisquer outras entradas que você salva. Elas existem apenas em um banco de dados criptografado no seu celular. Não podemos lê-las nem recuperá-las para você.\n\nO que o aplicativo envia: o PassKey inclui Google Firebase Crashlytics, Analytics e Performance Monitoring, além das atualizações no app do Google Play. Esses serviços enviam ao Google diagnósticos de falhas, eventos de uso, rastros de desempenho e identificadores de dispositivo e instalação, incluindo um identificador de publicidade. Eles nunca têm acesso ao conteúdo do seu cofre. Por causa desses serviços o app exige a permissão de internet, e o tratamento desses dados segue a política de privacidade do Google.\n\nBackups: ao exportar um backup, o arquivo é criptografado com uma senha escolhida por você e gravado onde você indicar ao sistema. Esse arquivo passa a ser responsabilidade sua; quem obtiver o arquivo e a senha poderá lê-lo. Não guardamos cópia.\n\nBackup do dispositivo: o PassKey está excluído do backup na nuvem do Android e da transferência entre dispositivos, então seu cofre não é enviado para sua conta Google.\n\nAlterações: esta política pode ser atualizada; mudanças relevantes serão indicadas nas notas de versão.\n\nDúvidas: escreva para yrkkh.cclub@gmail.com. Ao usar o PassKey você concorda com o seguinte.\n\n1. O PassKey é software gratuito e de código aberto desenvolvido por Aditya Bhardwaj.\n\n2. Seu cofre é armazenado apenas no seu dispositivo. Não existe cópia em servidor e, portanto, não há como recuperar seus dados se você perder o aparelho, desinstalar o app ou esquecer uma senha de backup.\n\n3. Manter backups é responsabilidade sua. Exporte-os regularmente e guarde a senha do backup em local seguro e separado.\n\n4. O app depende da segurança biométrica ou do bloqueio de tela do seu aparelho. Se esse bloqueio for fraco, removido ou comprometido, a proteção oferecida pelo PassKey diminui na mesma medida.\n\n5. O PassKey inclui serviços do Google Firebase e do Google Play conforme descrito na Política de Privacidade.\n\n6. O aplicativo é fornecido \"no estado em que se encontra\", sem garantia de qualquer tipo, expressa ou implícita, incluindo comercialização e adequação a uma finalidade específica.\n\n7. Na medida permitida por lei, o desenvolvedor não se responsabiliza por danos decorrentes do uso do aplicativo, incluindo perda de dados.\n\n8. O aplicativo pode ser modificado ou descontinuado a qualquer momento. + Defina uma senha de recuperação + O PassKey desbloqueia com sua digital ou o bloqueio de tela. Esta senha de recuperação é o caminho alternativo: você vai precisar dela se trocar suas digitais ou remover o bloqueio do aparelho. Não pode ser redefinida, guarde-a em local seguro. + Esta atualização recriptografa seu cofre com uma chave exclusiva deste aparelho. Defina antes uma senha de recuperação: é a única forma de voltar se você trocar as digitais ou remover o bloqueio. Não pode ser redefinida. + Digite sua senha de recuperação + A biometria ou o bloqueio de tela do aparelho mudaram, então o PassKey precisa da sua senha de recuperação. + Essa senha não funcionou. Tente de novo. + Protegendo seu cofre + Recriptografando suas entradas com uma chave exclusiva deste aparelho. Mantenha o app aberto. + Não foi possível concluir a proteção do cofre + Nada foi perdido e suas entradas estão intactas. Tente de novo ou reabra o app mais tarde. + Nada foi perdido — seu cofre está intacto e legível. Tente de novo, ou exporte antes um backup nas configurações. + Tentar de novo + Bloqueio de tela necessário + O PassKey protege seu cofre com o bloqueio de tela do aparelho. Configure PIN, padrão, senha ou biometria para continuar. + Abrir configurações \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7c68018..f071d1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -139,4 +139,19 @@ PassKey хранит ваши учётные данные, пароли и другие личные заметки в зашифрованной базе данных на вашем устройстве.\n\nВаше хранилище никогда не покидает телефон. Нет ни учётной записи, ни синхронизации, ни сервера PassKey: приложение не выполняет собственных сетевых запросов, и ваши записи никуда не передаются.\n\nБаза данных зашифрована с помощью SQLCipher и разблокируется биометрией или блокировкой экрана устройства. Хранилище автоматически блокируется, когда вы выходите из приложения, снимки экрана запрещены, а скопированные пароли помечаются как конфиденциальные и вскоре удаляются из буфера обмена.\n\nЗаписи можно распределять по категориям, менять их порядок перетаскиванием, генерировать надёжные пароли и проверять хранилище на слабые или повторяющиеся пароли. Резервные копии экспортируются одним файлом, зашифрованным выбранным вами паролем, и восстанавливаются на любом устройстве.\n\nPassKey — бесплатное приложение с открытым исходным кодом, разработанное Aditya Bhardwaj. Полный исходный код доступен на GitHub. Ваше хранилище остаётся на вашем устройстве.\n\nЧто мы никогда не собираем: PassKey не собирает, не передаёт и не хранит пароли, имена пользователей, заметки и любые другие сохранённые вами записи. Они существуют только в зашифрованной базе данных на вашем телефоне. Мы не можем ни прочитать их, ни восстановить для вас.\n\nЧто отправляет приложение: PassKey включает Google Firebase Crashlytics, Analytics и Performance Monitoring, а также встроенные обновления Google Play. Эти сервисы отправляют в Google диагностику сбоев, события использования, данные о производительности, а также идентификаторы устройства и установки, включая рекламный идентификатор. Доступа к содержимому хранилища у них нет никогда. Из-за этих сервисов приложению требуется разрешение на доступ в интернет, а обработка этих данных регулируется политикой конфиденциальности Google.\n\nРезервные копии: при экспорте файл шифруется выбранным вами паролем и сохраняется туда, куда вы укажете системе. Далее этот файл — ваша ответственность; любой, у кого окажутся и файл, и пароль, сможет его прочитать. Мы копий не храним.\n\nРезервное копирование устройства: PassKey исключено из облачного резервного копирования Android и переноса между устройствами, поэтому хранилище не выгружается в ваш аккаунт Google.\n\nИзменения: политика может обновляться; существенные изменения отмечаются в примечаниях к выпуску.\n\nВопросы: пишите на yrkkh.cclub@gmail.com. Используя PassKey, вы соглашаетесь со следующим.\n\n1. PassKey — бесплатное программное обеспечение с открытым исходным кодом, разработанное Aditya Bhardwaj.\n\n2. Ваше хранилище находится только на вашем устройстве. Серверной копии нет, а значит нет и способа восстановить данные, если вы потеряете устройство, удалите приложение или забудете пароль резервной копии.\n\n3. Резервные копии — ваша ответственность. Делайте их регулярно и храните пароль от копии в надёжном и отдельном месте.\n\n4. Приложение опирается на биометрию или блокировку экрана вашего устройства. Если эта защита слабая, отключена или скомпрометирована, защита со стороны PassKey снижается соответственно.\n\n5. PassKey включает сервисы Google Firebase и Google Play, как описано в политике конфиденциальности.\n\n6. Приложение предоставляется «как есть», без каких-либо гарантий, явных или подразумеваемых, включая пригодность для продажи и соответствие конкретной цели.\n\n7. В пределах, допускаемых законом, разработчик не несёт ответственности за ущерб, возникший в результате использования приложения, включая потерю данных.\n\n8. Приложение может быть изменено или прекращено в любое время. + Задайте пароль восстановления + PassKey открывается отпечатком или блокировкой экрана. Этот пароль восстановления — запасной путь: он понадобится, если вы измените отпечатки или отключите блокировку устройства. Сбросить его нельзя, храните в надёжном месте. + Это обновление заново шифрует хранилище ключом, уникальным для этого устройства. Сначала задайте пароль восстановления: это единственный способ вернуться, если вы измените отпечатки или отключите блокировку. Сбросить его нельзя. + Введите пароль восстановления + Биометрия или блокировка экрана на устройстве изменились, поэтому PassKey нужен пароль восстановления. + Этот пароль не подошёл. Попробуйте ещё раз. + Защищаем ваше хранилище + Записи заново шифруются ключом, уникальным для этого устройства. Не закрывайте приложение. + Не удалось завершить защиту хранилища + Ничего не потеряно, ваши записи не тронуты. Попробуйте ещё раз или откройте приложение позже. + Ничего не потеряно — хранилище цело и читается. Попробуйте ещё раз или сначала выгрузите резервную копию из настроек. + Повторить + Нужна блокировка устройства + PassKey защищает хранилище блокировкой экрана. Задайте PIN, графический ключ, пароль или биометрию, чтобы продолжить. + Открыть настройки diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 29657ee..bc7149a 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -137,4 +137,19 @@ PassKey உங்கள் உள்நுழைவுத் தகவல்கள், கடவுச்சொற்கள் மற்றும் பிற தனிப்பட்ட குறிப்புகளை உங்கள் சாதனத்தில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் சேமிக்கிறது.\n\nஉங்கள் பெட்டகம் ஒருபோதும் தொலைபேசியை விட்டு வெளியே செல்வதில்லை. கணக்கு இல்லை, ஒத்திசைவு இல்லை, PassKey சேவையகமும் இல்லை: ஆப்ஸ் தானாக எந்த நெட்வொர்க் கோரிக்கையும் செய்வதில்லை, உங்கள் பதிவுகள் எங்கும் அனுப்பப்படுவதில்லை.\n\nதரவுத்தளம் SQLCipher மூலம் மறையாக்கப்பட்டு, உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டால் திறக்கப்படுகிறது. ஆப்ஸை விட்டு வெளியேறியதும் பெட்டகம் தானாகவே மீண்டும் பூட்டப்படும், திரைப்பிடிப்புகள் தடுக்கப்படுகின்றன, நகலெடுத்த கடவுச்சொற்கள் முக்கியமானவை எனக் குறிக்கப்பட்டு சிறிது நேரத்தில் இடைநிலைப் பலகையிலிருந்து அழிக்கப்படுகின்றன.\n\nபதிவுகளை வகைகளாக ஒழுங்குபடுத்தலாம், இழுத்து வரிசை மாற்றலாம், வலுவான கடவுச்சொற்களை உருவாக்கலாம், பலவீனமான அல்லது மீண்டும் பயன்படுத்திய கடவுச்சொற்களைச் சரிபார்க்கலாம். காப்புப்பிரதிகள் நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்ட ஒற்றைக் கோப்பாக ஏற்றுமதி செய்யப்பட்டு எந்தச் சாதனத்திலும் மீட்டெடுக்கப்படலாம்.\n\nPassKey இலவசமானது, திறந்த மூலமானது, Aditya Bhardwaj உருவாக்கியது. முழு மூலக் குறியீடும் GitHub இல் கிடைக்கிறது. உங்கள் பெட்டகம் உங்கள் சாதனத்திலேயே இருக்கும்.\n\nநாங்கள் ஒருபோதும் சேகரிக்காதவை: நீங்கள் சேமிக்கும் கடவுச்சொற்கள், பயனர்பெயர்கள், குறிப்புகள் அல்லது பிற எந்தப் பதிவையும் PassKey சேகரிக்கவோ, அனுப்பவோ, சேமிக்கவோ இல்லை. அவை உங்கள் தொலைபேசியில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் மட்டுமே இருக்கும். அவற்றை எங்களால் படிக்கவும் முடியாது, உங்களுக்காக மீட்டெடுக்கவும் முடியாது.\n\nஆப்ஸ் அனுப்புபவை: PassKey இல் Google Firebase Crashlytics, Analytics, Performance Monitoring மற்றும் Google Play ஆப்ஸ்-உள் புதுப்பிப்புகள் உள்ளன. இவை செயலிழப்பு நோயறிதல், பயன்பாட்டு நிகழ்வுகள், செயல்திறன் தரவு, விளம்பர ID உள்ளிட்ட சாதன மற்றும் நிறுவல் அடையாளங்காட்டிகளை Google க்கு அனுப்புகின்றன. உங்கள் பெட்டக உள்ளடக்கத்திற்கு இவற்றுக்கு ஒருபோதும் அணுகல் இல்லை. இச்சேவைகளால் ஆப்ஸுக்கு இணைய அனுமதி தேவை, அவற்றின் தரவு கையாளுதல் Google இன் தனியுரிமைக் கொள்கையின் கீழ் வருகிறது.\n\nகாப்புப்பிரதிகள்: ஏற்றுமதி செய்யும்போது கோப்பு நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்டு, நீங்கள் கணினியிடம் குறிப்பிட்ட இடத்தில் சேமிக்கப்படும். அதன் பிறகு அக்கோப்பு உங்கள் பொறுப்பு; கோப்பையும் கடவுச்சொல்லையும் பெறுபவர் அதைப் படிக்க முடியும். நாங்கள் நகல் வைத்திருப்பதில்லை.\n\nசாதனக் காப்புப்பிரதி: PassKey Android கிளவுட் காப்புப்பிரதி மற்றும் சாதனங்களுக்கிடையேயான இடமாற்றத்திலிருந்து விலக்கப்பட்டுள்ளது, எனவே உங்கள் பெட்டகம் உங்கள் Google கணக்கிற்குப் பதிவேற்றப்படுவதில்லை.\n\nமாற்றங்கள்: இக்கொள்கை புதுப்பிக்கப்படலாம்; முக்கிய மாற்றங்கள் ஆப்ஸின் வெளியீட்டுக் குறிப்புகளில் தெரிவிக்கப்படும்.\n\nகேள்விகள்: தொடர்பு yrkkh.cclub@gmail.com. PassKey ஐப் பயன்படுத்துவதன் மூலம் பின்வருவனவற்றை ஏற்கிறீர்கள்.\n\n1. PassKey என்பது Aditya Bhardwaj உருவாக்கிய இலவச, திறந்த மூல மென்பொருள்.\n\n2. உங்கள் பெட்டகம் உங்கள் சாதனத்தில் மட்டுமே சேமிக்கப்படுகிறது. சேவையகத்தில் நகல் இல்லை, எனவே சாதனத்தை இழந்தால், ஆப்ஸை நீக்கினால் அல்லது காப்புப்பிரதி கடவுச்சொல்லை மறந்தால் தரவை மீட்க வழி இல்லை.\n\n3. காப்புப்பிரதிகளை வைத்திருப்பது உங்கள் பொறுப்பு. அவற்றை வழக்கமாக ஏற்றுமதி செய்து, காப்புப்பிரதி கடவுச்சொல்லைப் பாதுகாப்பான, தனித்த இடத்தில் வைக்கவும்.\n\n4. ஆப்ஸ் உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டுப் பாதுகாப்பை நம்பியுள்ளது. அப்பூட்டு பலவீனமாக, அகற்றப்பட்டதாக அல்லது சமரசம் செய்யப்பட்டதாக இருந்தால், PassKey அளிக்கும் பாதுகாப்பும் அதற்கேற்ப குறையும்.\n\n5. PassKey இல் தனியுரிமைக் கொள்கையில் விவரிக்கப்பட்ட Google Firebase மற்றும் Google Play சேவைகள் உள்ளன.\n\n6. இப்பயன்பாடு \"உள்ளபடி\" வழங்கப்படுகிறது, வணிகத்தகுதி மற்றும் குறிப்பிட்ட நோக்கத்திற்கான உகந்ததன்மை உட்பட, வெளிப்படையான அல்லது மறைமுகமான எந்த உத்தரவாதமும் இன்றி.\n\n7. சட்டம் அனுமதிக்கும் அளவில், பயன்பாட்டின் பயன்பாட்டால் ஏற்படும் எந்த சேதத்திற்கும், தரவு இழப்பு உட்பட, உருவாக்குநர் பொறுப்பல்ல.\n\n8. பயன்பாடு எந்த நேரத்திலும் மாற்றப்படலாம் அல்லது நிறுத்தப்படலாம். + மீட்பு கடவுச்சொல்லை அமைக்கவும் + PassKey உங்கள் கைரேகை அல்லது திரைப் பூட்டால் திறக்கிறது. இந்த மீட்பு கடவுச்சொல் மாற்று வழி — கைரேகைகளை மாற்றினாலோ சாதனப் பூட்டை நீக்கினாலோ இது தேவைப்படும். இதை மீட்டமைக்க முடியாது, பாதுகாப்பான இடத்தில் வைக்கவும். + இந்தப் புதுப்பிப்பு உங்கள் பெட்டகத்தை இந்தச் சாதனத்திற்கே உரிய சாவியால் மீண்டும் மறையாக்குகிறது. முதலில் மீட்பு கடவுச்சொல்லை அமைக்கவும்: கைரேகைகளை மாற்றினால் அல்லது பூட்டை நீக்கினால் இதுவே ஒரே வழி. இதை மீட்டமைக்க முடியாது. + உங்கள் மீட்பு கடவுச்சொல்லை உள்ளிடவும் + உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டு மாறியுள்ளது, எனவே பெட்டகத்தைத் திறக்க PassKey க்கு மீட்பு கடவுச்சொல் தேவை. + அந்தக் கடவுச்சொல் வேலை செய்யவில்லை. மீண்டும் முயலவும். + உங்கள் பெட்டகம் பாதுகாக்கப்படுகிறது + உங்கள் பதிவுகள் இந்தச் சாதனத்திற்கே உரிய சாவியால் மீண்டும் மறையாக்கப்படுகின்றன. ஆப்ஸைத் திறந்தே வைக்கவும். + பெட்டகப் பாதுகாப்பை முடிக்க முடியவில்லை + எதுவும் இழக்கப்படவில்லை, உங்கள் பதிவுகள் அப்படியே உள்ளன. மீண்டும் முயலவும், அல்லது பின்னர் ஆப்ஸைத் திறக்கவும். + எதுவும் இழக்கப்படவில்லை — உங்கள் பெட்டகம் பாதுகாப்பாகவும் படிக்கக்கூடியதாகவும் உள்ளது. மீண்டும் முயலவும், அல்லது அமைப்புகளிலிருந்து முதலில் காப்புப்பிரதி எடுக்கவும். + மீண்டும் முயலவும் + சாதனப் பூட்டு தேவை + PassKey உங்கள் பெட்டகத்தை சாதனத்தின் திரைப் பூட்டால் பாதுகாக்கிறது. தொடர பின், வடிவம், கடவுச்சொல் அல்லது உயிரியல் அடையாளத்தை அமைக்கவும். + அமைப்புகளைத் திற diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 6a8862b..6b84444 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -137,5 +137,20 @@ PassKey మీ లాగిన్‌లు, పాస్‌వర్డ్‌లు మరియు ఇతర వ్యక్తిగత గమనికలను మీ పరికరంలోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో నిల్వ చేస్తుంది.\n\nమీ వాల్ట్ ఎప్పుడూ మీ ఫోన్‌ను వదిలి వెళ్లదు. ఖాతా లేదు, సింక్ లేదు, PassKey సర్వర్ కూడా లేదు: యాప్ తనంతట తానుగా ఎలాంటి నెట్‌వర్క్ అభ్యర్థనలూ చేయదు, మీ ఎంట్రీలు ఎక్కడికీ పంపబడవు.\n\nడేటాబేస్ SQLCipher తో ఎన్‌క్రిప్ట్ చేయబడి, మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్ లాక్‌తో తెరుచుకుంటుంది. యాప్ నుండి బయటకు వెళ్లగానే వాల్ట్ స్వయంచాలకంగా మళ్లీ లాక్ అవుతుంది, స్క్రీన్‌షాట్‌లు నిరోధించబడ్డాయి, కాపీ చేసిన పాస్‌వర్డ్‌లు సున్నితమైనవిగా గుర్తించబడి కొద్దిసేపటిలో క్లిప్‌బోర్డ్ నుండి తొలగించబడతాయి.\n\nఎంట్రీలను వర్గాలుగా అమర్చవచ్చు, లాగి క్రమం మార్చవచ్చు, బలమైన పాస్‌వర్డ్‌లు సృష్టించవచ్చు, బలహీనమైన లేదా పునరావృత పాస్‌వర్డ్‌లను తనిఖీ చేయవచ్చు. బ్యాకప్‌లు మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ చేసిన ఒకే ఫైల్‌గా ఎగుమతి అవుతాయి, ఏ పరికరంలోనైనా పునరుద్ధరించవచ్చు.\n\nPassKey ఉచితం, ఓపెన్ సోర్స్, Aditya Bhardwaj అభివృద్ధి చేశారు. పూర్తి సోర్స్ కోడ్ GitHub లో అందుబాటులో ఉంది. మీ వాల్ట్ మీ పరికరంలోనే ఉంటుంది.\n\nమేము ఎప్పుడూ సేకరించనివి: మీరు సేవ్ చేసిన పాస్‌వర్డ్‌లు, వినియోగదారు పేర్లు, గమనికలు లేదా ఇతర ఏ ఎంట్రీలనూ PassKey సేకరించదు, పంపదు, నిల్వ చేయదు. అవి మీ ఫోన్‌లోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో మాత్రమే ఉంటాయి. మేము వాటిని చదవలేము, మీ కోసం తిరిగి పొందలేము.\n\nయాప్ పంపేవి: PassKey లో Google Firebase Crashlytics, Analytics, Performance Monitoring మరియు Google Play ఇన్-యాప్ అప్‌డేట్‌లు ఉన్నాయి. ఇవి క్రాష్ డయాగ్నస్టిక్స్, వినియోగ ఈవెంట్‌లు, పనితీరు డేటా, ప్రకటన IDతో సహా పరికర మరియు ఇన్‌స్టాలేషన్ గుర్తింపులను Google కు పంపుతాయి. మీ వాల్ట్ కంటెంట్‌కు వీటికి ఎప్పుడూ ప్రాప్యత ఉండదు. ఈ సేవల కారణంగా యాప్‌కు ఇంటర్నెట్ అనుమతి అవసరం, వాటి డేటా నిర్వహణ Google గోప్యతా విధానం ప్రకారం జరుగుతుంది.\n\nబ్యాకప్‌లు: ఎగుమతి చేసినప్పుడు ఫైల్ మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ అవుతుంది, మీరు సిస్టమ్‌కు చెప్పిన చోట నిల్వ అవుతుంది. ఆ తర్వాత ఆ ఫైల్ మీ బాధ్యత; ఫైల్ మరియు పాస్‌వర్డ్ రెండూ పొందిన ఎవరైనా దాన్ని చదవగలరు. మేము ఎలాంటి కాపీ ఉంచుకోము.\n\nపరికర బ్యాకప్: PassKey Android క్లౌడ్ బ్యాకప్ మరియు పరికరాల మధ్య బదిలీ నుండి మినహాయించబడింది, కాబట్టి మీ వాల్ట్ మీ Google ఖాతాకు అప్‌లోడ్ కాదు.\n\nమార్పులు: ఈ విధానం నవీకరించబడవచ్చు; ముఖ్యమైన మార్పులు యాప్ విడుదల గమనికలలో తెలియజేయబడతాయి.\n\nప్రశ్నలు: సంప్రదించండి yrkkh.cclub@gmail.com. PassKey ను ఉపయోగించడం ద్వారా మీరు కింది వాటికి అంగీకరిస్తున్నారు.\n\n1. PassKey అనేది Aditya Bhardwaj అభివృద్ధి చేసిన ఉచిత, ఓపెన్-సోర్స్ సాఫ్ట్‌వేర్.\n\n2. మీ వాల్ట్ మీ పరికరంలో మాత్రమే నిల్వ చేయబడుతుంది. సర్వర్‌లో కాపీ లేదు, కాబట్టి పరికరం పోగొట్టుకున్నా, యాప్ అన్‌ఇన్‌స్టాల్ చేసినా, బ్యాకప్ పాస్‌వర్డ్ మరచిపోయినా డేటాను తిరిగి పొందే మార్గం లేదు.\n\n3. బ్యాకప్‌లు ఉంచుకోవడం మీ బాధ్యత. వాటిని క్రమం తప్పకుండా ఎగుమతి చేయండి, బ్యాకప్ పాస్‌వర్డ్‌ను సురక్షితమైన, వేరే చోట ఉంచండి.\n\n4. యాప్ మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్-లాక్ భద్రతపై ఆధారపడి ఉంటుంది. ఆ లాక్ బలహీనంగా, తొలగించబడి లేదా రాజీపడి ఉంటే, PassKey అందించే రక్షణ కూడా అదే మేరకు తగ్గుతుంది.\n\n5. PassKey లో గోప్యతా విధానంలో వివరించిన Google Firebase మరియు Google Play సేవలు ఉన్నాయి.\n\n6. ఈ అప్లికేషన్ \"ఉన్నది ఉన్నట్లుగా\" అందించబడుతుంది, వర్తకయోగ్యత మరియు నిర్దిష్ట ప్రయోజనానికి అనుకూలత సహా, ఎలాంటి స్పష్టమైన లేదా అంతర్గత హామీ లేకుండా.\n\n7. చట్టం అనుమతించిన మేరకు, అప్లికేషన్ వినియోగం వల్ల కలిగే ఏ నష్టానికీ, డేటా నష్టంతో సహా, డెవలపర్ బాధ్యత వహించరు.\n\n8. అప్లికేషన్ ఎప్పుడైనా మార్చబడవచ్చు లేదా నిలిపివేయబడవచ్చు. + రికవరీ పాస్‌వర్డ్ సెట్ చేయండి + PassKey మీ వేలిముద్ర లేదా స్క్రీన్ లాక్‌తో తెరుచుకుంటుంది. ఈ రికవరీ పాస్‌వర్డ్ ప్రత్యామ్నాయ మార్గం — వేలిముద్రలు మార్చినా, పరికర లాక్ తీసేసినా ఇది అవసరం. దీన్ని రీసెట్ చేయలేరు, సురక్షితంగా ఉంచండి. + ఈ అప్‌డేట్ మీ వాల్ట్‌ను ఈ పరికరానికే ప్రత్యేకమైన కీతో మళ్లీ ఎన్‌క్రిప్ట్ చేస్తుంది. ముందుగా రికవరీ పాస్‌వర్డ్ సెట్ చేయండి: వేలిముద్రలు మార్చినా, లాక్ తీసేసినా ఇదే ఏకైక మార్గం. దీన్ని రీసెట్ చేయలేరు. + మీ రికవరీ పాస్‌వర్డ్ నమోదు చేయండి + మీ పరికర బయోమెట్రిక్ లేదా స్క్రీన్ లాక్ మారింది, కాబట్టి వాల్ట్ తెరవడానికి PassKey కి రికవరీ పాస్‌వర్డ్ కావాలి. + ఆ పాస్‌వర్డ్ పని చేయలేదు. మళ్లీ ప్రయత్నించండి. + మీ వాల్ట్‌ను భద్రపరుస్తున్నాం + మీ ఎంట్రీలు ఈ పరికరానికే ప్రత్యేకమైన కీతో మళ్లీ ఎన్‌క్రిప్ట్ అవుతున్నాయి. యాప్‌ను తెరిచే ఉంచండి. + వాల్ట్ భద్రపరచడం పూర్తి కాలేదు + ఏమీ పోలేదు, మీ ఎంట్రీలు యథాతథంగా ఉన్నాయి. మళ్లీ ప్రయత్నించండి, లేదా తర్వాత యాప్ తెరవండి. + ఏమీ పోలేదు — మీ వాల్ట్ చెక్కుచెదరలేదు, చదవగలిగేలా ఉంది. మళ్లీ ప్రయత్నించండి, లేదా ముందు సెట్టింగ్‌ల నుండి బ్యాకప్ తీసుకోండి. + మళ్లీ ప్రయత్నించు + పరికర లాక్ అవసరం + PassKey మీ వాల్ట్‌ను పరికర స్క్రీన్ లాక్‌తో కాపాడుతుంది. కొనసాగించడానికి పిన్, నమూనా, పాస్‌వర్డ్ లేదా బయోమెట్రిక్ సెట్ చేయండి. + సెట్టింగ్‌లు తెరువు diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0a28f55..6ef165c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -137,4 +137,19 @@ PassKey 将你的登录信息、密码和其他私密备注保存在设备上的加密数据库中。\n\n你的密码库永远不会离开手机。没有账号、没有同步、也没有 PassKey 服务器:应用本身不发起任何网络请求,你的条目不会被传输到任何地方。\n\n数据库使用 SQLCipher 加密,通过设备的生物识别或屏幕锁解锁。离开应用后密码库会自动重新锁定,截图被禁用,复制的密码会被标记为敏感内容并在稍后从剪贴板清除。\n\n你可以按类别整理条目、拖动排序、生成高强度密码,并检查密码库中是否存在弱密码或重复使用的密码。备份会导出为单个文件,使用你自己设定的密码加密,可在任意设备上恢复。\n\nPassKey 由 Aditya Bhardwaj 开发,免费且开源,完整源代码可在 GitHub 上获取。 你的密码库保留在你的设备上。\n\n我们从不收集的内容:PassKey 不会收集、传输或存储你保存的密码、用户名、备注或任何其他条目。它们只存在于你手机上的加密数据库中。我们既无法读取,也无法替你恢复。\n\n应用确实会发送的内容:PassKey 集成了 Google Firebase Crashlytics、Analytics 和 Performance Monitoring,以及 Google Play 应用内更新。这些服务会向 Google 发送崩溃诊断、使用事件、性能数据,以及包含广告 ID 在内的设备和安装标识符。它们始终无法访问你的密码库内容。由于这些服务,应用需要网络权限,其数据处理受 Google 隐私政策约束。\n\n备份:导出备份时,文件会使用你设定的密码加密,并保存到你指定的位置。此后该文件由你自行保管;同时获得文件和密码的人即可读取其内容。我们不保留任何副本。\n\n设备备份:PassKey 已排除在 Android 云备份和设备间转移之外,因此你的密码库不会上传到你的 Google 账号。\n\n变更:本政策可能更新,重大变更会在应用的版本说明中注明。\n\n如有疑问,请联系 yrkkh.cclub@gmail.com。 使用 PassKey 即表示你同意以下条款。\n\n1. PassKey 是由 Aditya Bhardwaj 开发的免费开源软件。\n\n2. 你的密码库仅存储在你的设备上。不存在服务器端副本,因此如果你丢失设备、卸载应用或忘记备份密码,将无法恢复数据。\n\n3. 保留备份是你的责任。请定期导出,并将备份密码保存在安全且独立的地方。\n\n4. 本应用依赖你设备的生物识别或屏幕锁安全性。若该锁定较弱、被移除或已被攻破,PassKey 提供的保护也会相应降低。\n\n5. PassKey 包含隐私政策中所述的 Google Firebase 和 Google Play 服务。\n\n6. 本应用按\"现状\"提供,不作任何明示或暗示的保证,包括适销性和特定用途适用性。\n\n7. 在法律允许的范围内,开发者不对因使用本应用而产生的任何损害负责,包括数据丢失。\n\n8. 本应用可能随时被修改或停止提供。 + 设置恢复密码 + PassKey 通过指纹或屏幕锁解锁。此恢复密码是备用入口——更换指纹或移除设备锁时会用到。它无法重置,请妥善保管。 + 此次更新会用本设备专属的密钥重新加密你的密码库。请先设置恢复密码:若更换指纹或移除设备锁,它是唯一的入口。它无法重置。 + 输入你的恢复密码 + 设备的生物识别或屏幕锁已更改,PassKey 需要你的恢复密码才能解锁密码库。 + 该密码无效,请重试。 + 正在保护你的密码库 + 正在用本设备专属的密钥重新加密你的条目,请保持应用开启。 + 未能完成密码库的保护 + 没有任何数据丢失,你的条目保持不变。请重试,或稍后重新打开应用。 + 没有任何数据丢失——你的密码库完好且可读。请重试,或先从设置中导出备份。 + 重试 + 需要设备锁 + PassKey 使用设备的屏幕锁保护你的密码库。请设置 PIN、图案、密码或生物识别以继续。 + 打开设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04b6399..61832fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,4 +208,21 @@ Questions: contact yrkkh.cclub@gm login credential + + + Set a recovery password + PassKey unlocks with your fingerprint or screen lock. This recovery password is the backup way in — you will need it if you change your fingerprints or remove your device lock. It cannot be reset, so store it somewhere safe. + This update re-encrypts your vault with a key unique to this device. Set a recovery password first: it is the only way back in if you change your fingerprints or remove your device lock. It cannot be reset, so store it somewhere safe. + Enter your recovery password + Your device biometrics or screen lock have changed, so PassKey needs your recovery password to unlock the vault. + That password did not work. Try again. + Securing your vault + Re-encrypting your entries with a key unique to this device. Please keep the app open. + Could not finish securing the vault + Nothing was lost and your entries are untouched. Try again, or reopen the app later. + Nothing was lost — your vault is intact and still readable. Try again, or export a backup first from Settings. + Try again + Device lock required + PassKey protects your vault with your device\'s screen lock. Set a PIN, pattern, password or biometric to continue. + Open settings \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43f97d6..11578b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,6 +62,7 @@ truth = "1.4.5" mockk = "1.14.11" turbine = "1.2.1" kotlinx_serialization = "1.11.0" +argon2kt = "1.6.0" [plugins] # Plugin definitions @@ -123,6 +124,7 @@ sqlcipher-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlcipher # Open CSV - Import/ Export Data opencsv = { module = "com.opencsv:opencsv", version.ref = "opencsv" } kotlinx_serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serialization" } +argon2kt = { module = "com.lambdapioneer.argon2kt:argon2kt", version.ref = "argon2kt" } # Reordering Lazy Columns reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } From 855695e5dd0ce7a4db7412f609d471a43c631be8 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 10:20:51 +0530 Subject: [PATCH 11/25] Finish 5.7.0: recovery password change, rescue export, and fix key wrapping Three remaining items, and one significant bug that on-device testing exposed. The bug first. Keystore wrapping keys were AES, and an auth-bound symmetric key requires authentication for *every* operation - including encryption. So wrapping the vault key during provisioning threw UserNotAuthenticatedException, which the per-slot runCatching swallowed, and installs silently ended up with only the recovery slot. The device showed REC alone even with a PIN set; an instrumented capability test confirmed isDeviceSecure=true and createCredentialKey succeeding, which narrowed it to the wrap rather than the key generation. The wrapping keys are now RSA-OAEP key pairs. The public key encrypts with no authentication, so the vault key can be wrapped at setup without prompting, while the private key requires authentication to decrypt. Wrapping is free, unwrapping is gated - which is the property the whole design assumed and did not have. MGF1 is pinned to SHA-1 in the OAEP spec despite the SHA-256 digest, because AndroidKeyStore records one digest for the key and applies it to OAEP itself; passing MGF1 SHA-256 fails on several implementations. Recovery password change (Settings): requires the current password rather than reusing an in-memory key. Proving knowledge of the old password is what stops someone with a briefly unlocked phone from silently replacing the recovery credential with their own, and it means the key never has to be retained for the session. Implemented as two steps over the existing password dialog. Rescue export: the un-migrated vault can now be read straight through the legacy key and written out as an encrypted backup. Offered in two places - before the re-key for upgrading users, and again on the migration failure screen. The original database is untouched by a failed export, so a user is never stuck behind a migration they cannot complete. The failure screen still deliberately offers no "reset". Two UI defects fixed while wiring that: the export-result Toast was being shown from composition rather than an effect, and the "export a backup first" button was rendered behind a non-dismissible dialog where it could never be tapped. Device verification on a wiped API 36 emulator with a known PIN and no enrolled fingerprint: - provisioning creates REC and CRED, and correctly skips BIO, which cannot be generated without an enrolled biometric - relaunch shows the credential prompt, the PIN unwraps the key through the CRED slot, and the vault opens - backgrounding past the 30s auto-lock re-prompts instead of showing entries 21 instrumented tests pass, including a KeyStoreWrapper capability test that reports what the device can actually do, since production deliberately swallows these failures and falls back. Still unverified on hardware: the BIO slot, meaning the CryptoObject-bound fingerprint unlock and the enrollment-change fall-through. Emulator fingerprint enrolment could not be driven reliably. That path needs a physical device with fingerprints enrolled before rollout. Version bumped to 5.7.0 (versionCode 46). Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 4 +- .../bhardwaj/passkey/KeyStoreWrapperTest.kt | 72 ++++++++++++++ .../passkey/data/backup/BackupRepository.kt | 73 ++++++++------ .../data/security/DatabaseKeyManager.kt | 50 ++++++++-- .../passkey/data/security/KeyStoreWrapper.kt | 98 ++++++++++++------- .../passkey/data/security/VaultMigration.kt | 53 ++++++++++ .../passkey/domain/events/SettingsEvents.kt | 15 +++ .../domain/viewModels/SettingsViewModel.kt | 44 +++++++++ .../domain/viewModels/VaultGateViewModel.kt | 28 ++++++ .../security_screen/VaultGateScreen.kt | 94 +++++++++++++++--- .../screens/settings_screen/SettingsPage.kt | 26 +++++ app/src/main/res/values-ar/strings.xml | 9 ++ app/src/main/res/values-bn/strings.xml | 9 ++ app/src/main/res/values-de/strings.xml | 9 ++ app/src/main/res/values-es/strings.xml | 9 ++ app/src/main/res/values-fr/strings.xml | 9 ++ app/src/main/res/values-gu/strings.xml | 9 ++ app/src/main/res/values-hi/strings.xml | 9 ++ app/src/main/res/values-it/strings.xml | 9 ++ app/src/main/res/values-ja/strings.xml | 9 ++ app/src/main/res/values-ko/strings.xml | 9 ++ app/src/main/res/values-mr/strings.xml | 9 ++ app/src/main/res/values-pt/strings.xml | 9 ++ app/src/main/res/values-ru/strings.xml | 9 ++ app/src/main/res/values-ta/strings.xml | 9 ++ app/src/main/res/values-te/strings.xml | 9 ++ app/src/main/res/values-zh/strings.xml | 9 ++ app/src/main/res/values/strings.xml | 11 +++ 28 files changed, 623 insertions(+), 89 deletions(-) create mode 100644 app/src/androidTest/java/com/bhardwaj/passkey/KeyStoreWrapperTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ef78ad..b8c4485 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.bhardwaj.passkey" minSdk = 28 targetSdk = 36 - versionCode = 45 - versionName = "5.6.0" + versionCode = 46 + versionName = "5.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/KeyStoreWrapperTest.kt b/app/src/androidTest/java/com/bhardwaj/passkey/KeyStoreWrapperTest.kt new file mode 100644 index 0000000..c3693c8 --- /dev/null +++ b/app/src/androidTest/java/com/bhardwaj/passkey/KeyStoreWrapperTest.kt @@ -0,0 +1,72 @@ +package com.bhardwaj.passkey + +import android.app.KeyguardManager +import androidx.core.content.getSystemService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.bhardwaj.passkey.data.security.KeyStoreWrapper +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Exercises real AndroidKeyStore key generation. + * + * These only mean anything on a device with a lock screen, so they report what the device can + * actually do rather than assuming. On a device with no credential, auth-bound keys cannot be + * generated at all and the app is expected to fall back to a recovery-password-only vault. + */ +@RunWith(AndroidJUnit4::class) +class KeyStoreWrapperTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val wrapper = KeyStoreWrapper(context) + + private val isDeviceSecure: Boolean + get() = context.getSystemService()?.isDeviceSecure == true + + @Before + fun setUp() = cleanUp() + + @After + fun tearDown() = cleanUp() + + private fun cleanUp() { + wrapper.delete(KeyStoreWrapper.ALIAS_BIO) + wrapper.delete(KeyStoreWrapper.ALIAS_CRED) + } + + @Test + fun report_device_capabilities() { + // Printed so a failing slot on CI or a given emulator can be diagnosed, since the + // production code deliberately swallows these failures and falls back. + println("KEYSTOREDIAG isDeviceSecure=$isDeviceSecure") + val bio = runCatching { wrapper.createBiometricKey() } + println("KEYSTOREDIAG createBiometricKey -> " + (bio.exceptionOrNull()?.toString() ?: "OK")) + val cred = runCatching { wrapper.createCredentialKey() } + println("KEYSTOREDIAG createCredentialKey -> " + (cred.exceptionOrNull()?.toString() ?: "OK")) + } + + @Test + fun credential_key_is_creatable_when_the_device_has_a_lock() { + if (!isDeviceSecure) return // nothing meaningful to assert + wrapper.createCredentialKey() + assertThat(wrapper.exists(KeyStoreWrapper.ALIAS_CRED)).isTrue() + // The property this whole design rests on: encryption uses the public key and needs no + // authentication, so the vault key can be wrapped during setup without a prompt. An + // auth-bound *symmetric* key would throw UserNotAuthenticatedException here. + val cipher = wrapper.initEncryptCipher(KeyStoreWrapper.ALIAS_CRED) + assertThat(cipher.doFinal(ByteArray(32))).isNotEmpty() + } + + @Test + fun deleting_an_alias_removes_it() { + if (!isDeviceSecure) return + wrapper.createCredentialKey() + assertThat(wrapper.exists(KeyStoreWrapper.ALIAS_CRED)).isTrue() + wrapper.delete(KeyStoreWrapper.ALIAS_CRED) + assertThat(wrapper.exists(KeyStoreWrapper.ALIAS_CRED)).isFalse() + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt index d87a9a8..a2eb905 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt @@ -52,36 +52,53 @@ class BackupRepository @Inject constructor( private val json = Json { ignoreUnknownKeys = false } suspend fun export(uri: Uri, password: CharArray): Result = withContext(Dispatchers.IO) { - try { - val previews = repository.getPreviews().first() - val payload = BackupPayload( - exportedAt = System.currentTimeMillis(), - appVersion = BuildConfig.VERSION_NAME, - previews = previews.map { preview -> - val details = repository.getDetailsByPreviewId(preview.previewId!!).first() - BackupPreview( - heading = preview.heading, - categoryName = preview.categoryName.name, - sequence = preview.sequence, - details = details.map { - BackupDetail(it.question, it.answer, it.sequence) - } - ) - } - ) - val plaintext = gzip(json.encodeToString(payload).toByteArray(Charsets.UTF_8)) - val encrypted = try { - BackupCrypto.encrypt(plaintext, password) - } finally { - Arrays.fill(plaintext, 0) + val previews = runCatching { + repository.getPreviews().first().map { preview -> + BackupPreview( + heading = preview.heading, + categoryName = preview.categoryName.name, + sequence = preview.sequence, + details = repository.getDetailsByPreviewId(preview.previewId!!).first() + .map { BackupDetail(it.question, it.answer, it.sequence) } + ) } - // "wt" truncates: CreateDocument can hand back an existing file. - context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(encrypted) } - ?: return@withContext Result.failure(BackupException(BackupError.WriteFailed)) - Result.success(Unit) - } catch (_: Exception) { - Result.failure(BackupException(BackupError.WriteFailed)) + }.getOrElse { return@withContext Result.failure(BackupException(BackupError.ReadFailed)) } + writeEncrypted(uri, password, previews) + } + + /** + * Writes a backup assembled outside the normal repository path. + * + * Used by the migration failure screen, which reads the un-migrated vault through the legacy + * key so a user whose re-key could not complete can still get their data out. + */ + internal suspend fun exportPreviews( + uri: Uri, + password: CharArray, + previews: List + ): Result = withContext(Dispatchers.IO) { writeEncrypted(uri, password, previews) } + + private fun writeEncrypted( + uri: Uri, + password: CharArray, + previews: List + ): Result = try { + val payload = BackupPayload( + exportedAt = System.currentTimeMillis(), + appVersion = BuildConfig.VERSION_NAME, + previews = previews + ) + val plaintext = gzip(json.encodeToString(payload).toByteArray(Charsets.UTF_8)) + val encrypted = try { + BackupCrypto.encrypt(plaintext, password) + } finally { + Arrays.fill(plaintext, 0) } + context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(encrypted) } + ?: return Result.failure(BackupException(BackupError.WriteFailed)) + Result.success(Unit) + } catch (_: Exception) { + Result.failure(BackupException(BackupError.WriteFailed)) } suspend fun import( diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt index 897942a..bb3171f 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/DatabaseKeyManager.kt @@ -143,7 +143,7 @@ class DatabaseKeyManager @Inject constructor( ): UnlockResult? { val slot = file.slot(SlotId.BIO) as? KeySlot.Keystore ?: return null val cipher = try { - keyStore.initDecryptCipher(slot.alias, slot.ivB64.decodeB64()) + keyStore.initDecryptCipher(slot.alias) } catch (_: KeyPermanentlyInvalidatedException) { return null // enrollment changed - fall through to CRED } catch (_: Exception) { @@ -183,7 +183,7 @@ class DatabaseKeyManager @Inject constructor( return try { // Must happen inside the key's validity window opened by the prompt above. - val cipher = keyStore.initDecryptCipher(slot.alias, slot.ivB64.decodeB64()) + val cipher = keyStore.initDecryptCipher(slot.alias) val dek = cipher.doFinal(slot.wrappedKeyB64.decodeB64()) repairBiometricSlot(file, dek) UnlockResult.Success(dek) @@ -221,11 +221,42 @@ class DatabaseKeyManager @Inject constructor( } } - suspend fun changeRecoveryPassword(dek: ByteArray, newPassword: CharArray) = - mutex.withLock { - val file = slotStore.load() ?: return@withLock - slotStore.save(file.withSlot(wrapWithPassword(SlotId.REC, dek, newPassword))) + /** + * Re-wraps the key under a new recovery password. + * + * The current password is required rather than reusing an in-memory key: proving knowledge + * of the old password is what stops someone who picks up an unlocked phone from silently + * replacing the recovery credential with one of their own. It also means the key never has + * to be retained in memory for the lifetime of the session. + */ + suspend fun changeRecoveryPassword( + currentPassword: CharArray, + newPassword: CharArray + ): Boolean = mutex.withLock { + withContext(Dispatchers.IO) { + val file = slotStore.load() ?: return@withContext false + val slot = file.slot(SlotId.REC) as? KeySlot.Password ?: return@withContext false + + val wrappingKey = keyDerivation.derive(currentPassword, slot.kdf) + val dek = try { + AesGcm.decrypt( + key = wrappingKey, + iv = slot.ivB64.decodeB64(), + ciphertext = slot.wrappedKeyB64.decodeB64() + ) + } finally { + Arrays.fill(wrappingKey, 0) + } ?: return@withContext false + + try { + if (dekCheck(dek) != file.dekCheck) return@withContext false + slotStore.save(file.withSlot(wrapWithPassword(SlotId.REC, dek, newPassword))) + true + } finally { + Arrays.fill(dek, 0) + } } + } suspend fun markMigrationState(state: MigrationState) = mutex.withLock { slotStore.load()?.let { slotStore.save(it.copy(migrationState = state)) } @@ -259,13 +290,14 @@ class DatabaseKeyManager @Inject constructor( } private fun wrapWithKeystore(id: SlotId, alias: String, dek: ByteArray): KeySlot.Keystore { + // Public-key encryption, so this needs no authentication and can run during setup. val cipher: Cipher = keyStore.initEncryptCipher(alias) - val wrapped = cipher.doFinal(dek) return KeySlot.Keystore( id = id, alias = alias, - ivB64 = cipher.iv.encodeB64(), - wrappedKeyB64 = wrapped.encodeB64() + // RSA-OAEP carries no IV; the field stays for the password slots. + ivB64 = "", + wrappedKeyB64 = cipher.doFinal(dek).encodeB64() ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt index d0dd7db..9d40527 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/KeyStoreWrapper.kt @@ -7,17 +7,25 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.KeyPairGenerator import java.security.KeyStore +import java.security.spec.MGF1ParameterSpec import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource import javax.inject.Inject import javax.inject.Singleton /** * Creates and uses the AndroidKeyStore keys that wrap the vault's data encryption key. * + * These are **RSA key pairs, not AES keys**, and that is the whole point. An auth-bound symmetric + * key requires authentication for *every* operation including encryption, so wrapping the vault + * key at provisioning time would throw UserNotAuthenticatedException - there is nothing to + * authenticate against yet, and prompting during setup would be the wrong moment. With a key + * pair, the public key encrypts with no authentication at all, while the private key requires it + * to decrypt. Wrapping is free; unwrapping is gated. + * * Two aliases exist, deliberately with different auth properties: * * - [ALIAS_BIO] requires per-use strong biometric auth and is bound to the current biometric @@ -36,11 +44,8 @@ class KeyStoreWrapper @Inject constructor( const val ALIAS_CRED = "passkey_dek_wrap_cred" private const val PROVIDER = "AndroidKeyStore" - private const val TRANSFORMATION = - "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/" + - "${KeyProperties.ENCRYPTION_PADDING_NONE}" - private const val GCM_TAG_BITS = 128 - private const val KEY_BITS = 256 + private const val TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + private const val KEY_BITS = 2048 /** Seconds the credential-backed key stays usable after a successful auth. */ const val CRED_VALIDITY_SECONDS = 30 @@ -53,41 +58,59 @@ class KeyStoreWrapper @Inject constructor( fun exists(alias: String): Boolean = runCatching { keyStore.containsAlias(alias) } .getOrDefault(false) - fun getOrNull(alias: String): SecretKey? = runCatching { - keyStore.getKey(alias, null) as? SecretKey - }.getOrNull() - fun delete(alias: String) { runCatching { keyStore.deleteEntry(alias) } } - fun createBiometricKey(): SecretKey = generate(ALIAS_BIO, biometricOnly = true) + fun createBiometricKey() = generate(ALIAS_BIO, biometricOnly = true) - fun createCredentialKey(): SecretKey = generate(ALIAS_CRED, biometricOnly = false) + fun createCredentialKey() = generate(ALIAS_CRED, biometricOnly = false) /** - * @throws android.security.keystore.KeyPermanentlyInvalidatedException when the enrollment - * backing the key has changed. Thrown by init, *before* any prompt is shown, which is what - * lets the caller fall through to another slot without the user seeing a broken prompt. + * Encryption uses the public key, which is never auth-gated, so this can be called at + * provisioning time without a prompt. */ - fun initEncryptCipher(alias: String): Cipher = - Cipher.getInstance(TRANSFORMATION).apply { - init(Cipher.ENCRYPT_MODE, requireKey(alias)) + fun initEncryptCipher(alias: String): Cipher { + val certificate = keyStore.getCertificate(alias) + ?: error("Keystore alias $alias is missing") + return Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, certificate.publicKey, oaepSpec()) } + } - /** @throws android.security.keystore.KeyPermanentlyInvalidatedException — see above. */ - fun initDecryptCipher(alias: String, iv: ByteArray): Cipher = - Cipher.getInstance(TRANSFORMATION).apply { - init(Cipher.DECRYPT_MODE, requireKey(alias), GCMParameterSpec(GCM_TAG_BITS, iv)) + /** + * Decryption uses the private key and therefore requires authentication. + * + * @throws android.security.keystore.KeyPermanentlyInvalidatedException when the enrollment + * backing the key has changed. Thrown here, *before* any prompt is shown, which is what + * lets the caller fall through to another slot without the user seeing a broken prompt. + */ + fun initDecryptCipher(alias: String): Cipher { + val privateKey = keyStore.getKey(alias, null) + ?: error("Keystore alias $alias is missing") + return Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, privateKey, oaepSpec()) } + } - private fun requireKey(alias: String): SecretKey = - getOrNull(alias) ?: error("Keystore alias $alias is missing") - - private fun generate(alias: String, biometricOnly: Boolean): SecretKey { - // StrongBox can throw at generateKey() even when the feature flag is advertised, so it - // is attempted and then abandoned rather than trusted. - return runCatching { generate(alias, biometricOnly, strongBox = supportsStrongBox()) } + /** + * MGF1 is pinned to SHA-1 even though the digest is SHA-256. + * + * AndroidKeyStore only records a single digest for the key and applies it to OAEP itself; + * passing MGF1ParameterSpec.SHA256 here makes init fail with an unsupported-MGF error on + * several OEM implementations. This is the combination the platform actually accepts. + */ + private fun oaepSpec() = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA1, + PSource.PSpecified.DEFAULT + ) + + private fun generate(alias: String, biometricOnly: Boolean) { + // StrongBox can throw at generation even when the feature flag is advertised, and it + // frequently does not support RSA at all, so it is attempted and then abandoned. + runCatching { generate(alias, biometricOnly, strongBox = supportsStrongBox()) } .recoverCatching { error -> if (error is StrongBoxUnavailableException) { delete(alias) @@ -99,15 +122,14 @@ class KeyStoreWrapper @Inject constructor( .getOrThrow() } - private fun generate(alias: String, biometricOnly: Boolean, strongBox: Boolean): SecretKey { + private fun generate(alias: String, biometricOnly: Boolean, strongBox: Boolean) { val builder = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(KEY_BITS) - .setRandomizedEncryptionRequired(true) + .setDigests(KeyProperties.DIGEST_SHA256) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) .setUserAuthenticationRequired(true) // API 28 exactly, which is this app's minSdk: the key is unusable while the device // is locked, so a stolen unlocked-but-idle phone is not a bypass. @@ -141,9 +163,9 @@ class KeyStoreWrapper @Inject constructor( builder.setIsStrongBoxBacked(true) } - return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).apply { - init(builder.build()) - }.generateKey() + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, PROVIDER).apply { + initialize(builder.build()) + }.generateKeyPair() } private fun supportsStrongBox(): Boolean = diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt index e2ee090..dd784e8 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/VaultMigration.kt @@ -3,6 +3,8 @@ package com.bhardwaj.passkey.data.security import android.content.Context import android.os.StatFs import com.bhardwaj.passkey.BuildConfig +import com.bhardwaj.passkey.data.backup.BackupDetail +import com.bhardwaj.passkey.data.backup.BackupPreview import com.bhardwaj.passkey.data.local.toSqlCipherRawKey import com.bhardwaj.passkey.utils.Constants import dagger.hilt.android.qualifiers.ApplicationContext @@ -149,6 +151,57 @@ class VaultMigration @Inject constructor( File(original.parentFile, "${Constants.PASS_KEY_DATABASE}.bak").delete() } + /** + * Reads the un-migrated vault straight through the legacy key. + * + * This is what makes a failed migration recoverable rather than a dead end: the original + * database is untouched by a failed export, so its contents can still be written out to an + * encrypted backup the user controls. + */ + internal suspend fun readLegacyVault(): List? = withContext(Dispatchers.IO) { + val original = context.getDatabasePath(Constants.PASS_KEY_DATABASE) + if (!original.exists()) return@withContext null + val db = openWithAnyLegacyKey(original) ?: return@withContext null + try { + val details = mutableMapOf>() + db.rawQuery( + "SELECT previewId, question, answer, sequence FROM ${Constants.DETAILS_TABLE}", + null + ).use { cursor -> + while (cursor.moveToNext()) { + details.getOrPut(cursor.getLong(0)) { mutableListOf() }.add( + BackupDetail( + question = cursor.getString(1), + answer = cursor.getString(2), + sequence = cursor.getLong(3) + ) + ) + } + } + db.rawQuery( + "SELECT previewId, heading, categoryName, sequence FROM ${Constants.PREVIEW_TABLE}", + null + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + BackupPreview( + heading = cursor.getString(1), + categoryName = cursor.getString(2), + sequence = cursor.getLong(3), + details = details[cursor.getLong(0)].orEmpty() + ) + ) + } + } + } + } catch (_: Exception) { + null + } finally { + runCatching { db.close() } + } + } + /** * Still openable with the legacy key, which is what lets the failure screen offer an export * even when the migration cannot complete. diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt b/app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt index 6bcb92a..39fafcc 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt @@ -45,6 +45,21 @@ sealed interface SettingsEvents { data object OnAnalyzePasswordsClick : SettingsEvents data object OnDismissAnalysisSheet : SettingsEvents data object OnAutoLockClick : SettingsEvents + data object OnChangeRecoveryPasswordClick : SettingsEvents + data object OnDismissRecoveryChange : SettingsEvents + data class OnCurrentRecoveryPasswordEntered(val password: CharArray) : SettingsEvents { + override fun equals(other: Any?) = this === other || + (other is OnCurrentRecoveryPasswordEntered && password.contentEquals(other.password)) + + override fun hashCode() = password.contentHashCode() + } + + data class OnNewRecoveryPasswordEntered(val password: CharArray) : SettingsEvents { + override fun equals(other: Any?) = this === other || + (other is OnNewRecoveryPasswordEntered && password.contentEquals(other.password)) + + override fun hashCode() = password.contentHashCode() + } data object OnDismissAutoLockDialog : SettingsEvents data class OnAutoLockTimeoutChange(val timeout: AutoLockTimeout) : SettingsEvents } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt index 40947b4..5d132f2 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt @@ -18,6 +18,7 @@ import com.bhardwaj.passkey.data.backup.BackupError import com.bhardwaj.passkey.data.backup.BackupException import com.bhardwaj.passkey.data.backup.BackupRepository import com.bhardwaj.passkey.data.security.AutoLockTimeout +import com.bhardwaj.passkey.data.security.DatabaseKeyManager import com.bhardwaj.passkey.data.repository.DataStoreRepository import com.bhardwaj.passkey.data.repository.PasskeyRepository import com.bhardwaj.passkey.domain.events.SettingsEvents @@ -50,6 +51,7 @@ class SettingsViewModel @Inject constructor( private val repository: PasskeyRepository, private val dataStoreRepository: DataStoreRepository, private val backupRepository: BackupRepository, + private val keyManager: DatabaseKeyManager, private val appContext: Application ) : ViewModel() { private val _uiEvents = Channel() @@ -78,6 +80,13 @@ class SettingsViewModel @Inject constructor( var isAutoLockDialogOpen by mutableStateOf(false) private set + /** null = closed, false = asking for the current password, true = asking for the new one. */ + var recoveryChangeStep by mutableStateOf(null) + private set + + /** Held only between the two dialog steps, then zeroed. */ + private var pendingCurrentRecoveryPassword: CharArray? = null + val autoLockTimeout: StateFlow = flow { emitAll( dataStoreRepository.readPreference( @@ -204,6 +213,41 @@ class SettingsViewModel @Inject constructor( } } + SettingsEvents.OnChangeRecoveryPasswordClick -> { + recoveryChangeStep = false + } + + SettingsEvents.OnDismissRecoveryChange -> { + pendingCurrentRecoveryPassword?.let { java.util.Arrays.fill(it, Char(0)) } + pendingCurrentRecoveryPassword = null + recoveryChangeStep = null + } + + is SettingsEvents.OnCurrentRecoveryPasswordEntered -> { + pendingCurrentRecoveryPassword = event.password + recoveryChangeStep = true + } + + is SettingsEvents.OnNewRecoveryPasswordEntered -> { + val current = pendingCurrentRecoveryPassword + pendingCurrentRecoveryPassword = null + recoveryChangeStep = null + viewModelScope.launch { + val changed = current != null && + keyManager.changeRecoveryPassword(current, event.password) + current?.let { java.util.Arrays.fill(it, Char(0)) } + java.util.Arrays.fill(event.password, Char(0)) + sendUiEvents( + UiEvents.ShowSnackBar( + UiText.StringResource( + if (changed) R.string.recovery_change_done + else R.string.recovery_change_failed + ) + ) + ) + } + } + SettingsEvents.OnAutoLockClick -> { isAutoLockDialogOpen = true } diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt index c4cdef6..c4312a3 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt @@ -3,6 +3,8 @@ package com.bhardwaj.passkey.domain.viewModels import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import android.net.Uri +import com.bhardwaj.passkey.data.backup.BackupRepository import com.bhardwaj.passkey.data.local.VaultDatabaseProvider import com.bhardwaj.passkey.data.security.DatabaseKeyManager import com.bhardwaj.passkey.data.security.MigrationState @@ -24,6 +26,7 @@ import javax.inject.Inject class VaultGateViewModel @Inject constructor( private val keyManager: DatabaseKeyManager, private val migration: VaultMigration, + private val backupRepository: BackupRepository, private val vault: VaultDatabaseProvider, private val session: VaultSession ) : ViewModel() { @@ -55,6 +58,10 @@ class VaultGateViewModel @Inject constructor( private val _state = MutableStateFlow(State.Checking) val state: StateFlow = _state.asStateFlow() + /** null = nothing to report, true/false = the outcome of a rescue export. */ + private val _exportResult = MutableStateFlow(null) + val exportResult: StateFlow = _exportResult.asStateFlow() + /** Set when a wrong recovery password was entered, so the UI can say so. */ private val _recoveryFailed = MutableStateFlow(false) val recoveryFailed: StateFlow = _recoveryFailed.asStateFlow() @@ -127,6 +134,27 @@ class VaultGateViewModel @Inject constructor( fun onRetry() = start() + /** + * Exports the un-migrated vault by reading it through the legacy key. + * + * Offered before the re-key and again if it fails. The point is that a user is never stuck + * behind a migration they cannot complete: the original database is untouched by a failed + * export, so its contents can always be written out to a backup they control. + */ + fun onExportLegacyVault(uri: Uri, password: CharArray) { + viewModelScope.launch { + val previews = migration.readLegacyVault() + val success = previews != null && + backupRepository.exportPreviews(uri, password, previews).isSuccess + Arrays.fill(password, NUL) + _exportResult.value = success + } + } + + fun onExportResultShown() { + _exportResult.value = null + } + /** * The re-key runs after unlock, because the new key must be wrapped by an auth-bound * Keystore key before it is used to encrypt anything. diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt index 3c39cf3..55a080b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt @@ -1,5 +1,15 @@ package com.bhardwaj.passkey.presentation.screens.security_screen +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import android.content.Intent import android.provider.Settings import androidx.activity.compose.LocalActivity @@ -50,6 +60,39 @@ fun VaultGateScreen( val promptSubtitle = stringResource(R.string.security_description) val promptNegative = stringResource(R.string.cancel) + // Rescue export: reads the un-migrated vault through the legacy key so a user is never + // stuck behind a re-key they cannot complete. + var pendingExportUri by remember { mutableStateOf(null) } + var showRecoverySetupDialog by remember { mutableStateOf(false) } + val exportResult by viewModel.exportResult.collectAsStateWithLifecycle() + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> pendingExportUri = uri } + + val exportSucceededText = stringResource(R.string.export_success) + val exportFailedText = stringResource(R.string.export_failed) + LaunchedEffect(exportResult) { + exportResult?.let { succeeded -> + Toast.makeText( + context, + if (succeeded) exportSucceededText else exportFailedText, + Toast.LENGTH_LONG + ).show() + viewModel.onExportResultShown() + } + } + + pendingExportUri?.let { uri -> + BackupPasswordDialog( + confirmMode = true, + onDismiss = { pendingExportUri = null }, + onConfirm = { password -> + pendingExportUri = null + viewModel.onExportLegacyVault(uri, password) + } + ) + } + LaunchedEffect(Unit) { viewModel.start() } LaunchedEffect(state) { @@ -64,18 +107,34 @@ fun VaultGateScreen( } when (val current = state) { - is VaultGateViewModel.State.SetUpRecovery -> BackupPasswordDialog( - confirmMode = true, - titleRes = R.string.recovery_setup_title, - messageRes = if (current.isExistingVault) { - R.string.recovery_setup_message_upgrade + is VaultGateViewModel.State.SetUpRecovery -> { + // A fresh install has nothing to back up, so it goes straight to the dialog. An + // upgrading user gets a screen first, because the "export a backup" offer has to be + // reachable - and the dialog itself is deliberately non-dismissible. + if (!current.isExistingVault || showRecoverySetupDialog) { + BackupPasswordDialog( + confirmMode = true, + titleRes = R.string.recovery_setup_title, + messageRes = if (current.isExistingVault) { + R.string.recovery_setup_message_upgrade + } else { + R.string.recovery_setup_message_new + }, + dismissible = false, + onDismiss = {}, + onConfirm = viewModel::onRecoveryPasswordChosen + ) } else { - R.string.recovery_setup_message_new - }, - dismissible = false, - onDismiss = {}, - onConfirm = viewModel::onRecoveryPasswordChosen - ) + GateMessage( + title = stringResource(R.string.recovery_setup_title), + message = stringResource(R.string.recovery_setup_message_upgrade), + primaryLabel = stringResource(R.string.backup_confirm), + onPrimary = { showRecoverySetupDialog = true }, + secondaryLabel = stringResource(R.string.migration_export_first), + onSecondary = { exportLauncher.launch(defaultRescueFileName()) } + ) + } + } VaultGateViewModel.State.NeedsRecoveryPassword -> BackupPasswordDialog( confirmMode = false, @@ -109,7 +168,13 @@ fun VaultGateScreen( stringResource(R.string.migration_failed_message) }, primaryLabel = stringResource(R.string.migration_retry), - onPrimary = viewModel::onRetry + onPrimary = viewModel::onRetry, + secondaryLabel = if (current.canExportVault) { + stringResource(R.string.migration_export_now) + } else { + null + }, + onSecondary = { exportLauncher.launch(defaultRescueFileName()) } ) VaultGateViewModel.State.NeedsDeviceLock -> GateMessage( @@ -148,6 +213,11 @@ fun VaultGateScreen( } } +private fun defaultRescueFileName(): String { + val stamp = SimpleDateFormat("yyyyMMdd_HHmm", Locale.US).format(Date()) + return "passkey_backup_$stamp.pkbak" +} + @Composable private fun GateMessage( title: String? = null, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index 6261c71..5fb5a24 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -105,6 +105,27 @@ fun SettingsScreen( } } + // Two steps: prove the current password, then choose the new one. Reuses the same dialog. + when (viewModel.recoveryChangeStep) { + false -> BackupPasswordDialog( + confirmMode = false, + titleRes = R.string.recovery_change_current_title, + messageRes = R.string.recovery_change_current_message, + onDismiss = { viewModel.onEvent(SettingsEvents.OnDismissRecoveryChange) }, + onConfirm = { viewModel.onEvent(SettingsEvents.OnCurrentRecoveryPasswordEntered(it)) } + ) + + true -> BackupPasswordDialog( + confirmMode = true, + titleRes = R.string.recovery_change_new_title, + messageRes = R.string.recovery_change_new_message, + onDismiss = { viewModel.onEvent(SettingsEvents.OnDismissRecoveryChange) }, + onConfirm = { viewModel.onEvent(SettingsEvents.OnNewRecoveryPasswordEntered(it)) } + ) + + null -> Unit + } + val autoLockTimeout by viewModel.autoLockTimeout.collectAsStateWithLifecycle() if (viewModel.isAutoLockDialogOpen) { AutoLockDialog( @@ -249,6 +270,11 @@ fun SettingsScreen( SettingsText(text = stringResource(id = R.string.auto_lock)) { viewModel.onEvent(SettingsEvents.OnAutoLockClick) } + SettingsText( + text = stringResource(id = R.string.change_recovery_password) + ) { + viewModel.onEvent(SettingsEvents.OnChangeRecoveryPasswordClick) + } SettingsText(text = stringResource(id = R.string.import_data)) { importLauncher.launch(arrayOf("*/*")) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e1b592d..135350e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -157,4 +157,13 @@ قفل الجهاز مطلوب يحمي PassKey خزنتك بقفل شاشة جهازك. عيّن رمز PIN أو نمطًا أو كلمة مرور أو بصمة للمتابعة. فتح الإعدادات + تغيير كلمة مرور الاسترداد + تأكيد كلمة المرور الحالية + أدخل كلمة مرور الاسترداد الحالية لإثبات هويتك. + اختر كلمة مرور استرداد جديدة + تحل هذه محل كلمة مرور الاسترداد القديمة. لا يمكن إعادة تعيينها، فاحفظها في مكان آمن. + تم تغيير كلمة مرور الاسترداد. + كلمة المرور غير صحيحة. + صدّر نسخة احتياطية أولًا + صدّر خزنتي الآن \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index f6e4460..4930c43 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -153,4 +153,13 @@ ডিভাইস লক প্রয়োজন PassKey আপনার ভল্ট ডিভাইসের স্ক্রিন লক দিয়ে সুরক্ষিত রাখে। চালিয়ে যেতে পিন, প্যাটার্ন, পাসওয়ার্ড বা বায়োমেট্রিক সেট করুন। সেটিংস খুলুন + পুনরুদ্ধার পাসওয়ার্ড বদলান + বর্তমান পাসওয়ার্ড নিশ্চিত করুন + আপনিই যে তা প্রমাণ করতে বর্তমান পুনরুদ্ধার পাসওয়ার্ড দিন। + নতুন পুনরুদ্ধার পাসওয়ার্ড বাছুন + এটি আপনার পুরনো পুনরুদ্ধার পাসওয়ার্ড প্রতিস্থাপন করে। এটি রিসেট করা যায় না, তাই নিরাপদে রাখুন। + পুনরুদ্ধার পাসওয়ার্ড বদলানো হয়েছে। + পাসওয়ার্ডটি সঠিক ছিল না। + আগে ব্যাকআপ নিন + এখনই আমার ভল্ট রপ্তানি করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9078da4..5f65c29 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -154,4 +154,13 @@ Gerätesperre erforderlich PassKey schützt deinen Tresor mit der Bildschirmsperre deines Geräts. Richte PIN, Muster, Passwort oder Biometrie ein, um fortzufahren. Einstellungen öffnen + Wiederherstellungspasswort ändern + Aktuelles Passwort bestätigen + Gib dein bisheriges Wiederherstellungspasswort ein, um dich auszuweisen. + Neues Wiederherstellungspasswort wählen + Das ersetzt dein bisheriges Wiederherstellungspasswort. Es lässt sich nicht zurücksetzen, bewahre es sicher auf. + Wiederherstellungspasswort geändert. + Das Passwort war nicht korrekt. + Zuerst ein Backup exportieren + Tresor jetzt exportieren \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5e6366b..779bcde 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -154,4 +154,13 @@ Se requiere bloqueo de dispositivo PassKey protege tu caja fuerte con el bloqueo de pantalla. Configura un PIN, patrón, contraseña o biometría para continuar. Abrir ajustes + Cambiar contraseña de recuperación + Confirma la contraseña actual + Introduce tu contraseña de recuperación actual para demostrar que eres tú. + Elige una nueva contraseña de recuperación + Sustituye a tu contraseña de recuperación anterior. No se puede restablecer, guárdala en un lugar seguro. + Contraseña de recuperación cambiada. + Esa contraseña no era correcta. + Exportar una copia primero + Exportar mi caja fuerte ahora diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 476a7af..a0677b4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -154,4 +154,13 @@ Verrouillage de l\'appareil requis PassKey protège votre coffre avec le verrouillage d\'écran. Configurez un code, un schéma, un mot de passe ou la biométrie pour continuer. Ouvrir les paramètres + Changer le mot de passe de secours + Confirmez le mot de passe actuel + Saisissez votre mot de passe de secours actuel pour prouver que c\'est bien vous. + Choisissez un nouveau mot de passe de secours + Il remplace votre ancien mot de passe de secours. Il ne peut pas être réinitialisé, conservez-le en lieu sûr. + Mot de passe de secours modifié. + Ce mot de passe n\'était pas correct. + Exporter une sauvegarde d\'abord + Exporter mon coffre maintenant diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index 6383e50..c43ceac 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -152,4 +152,13 @@ ઉપકરણ લૉક જરૂરી PassKey તમારી વૉલ્ટને ઉપકરણના સ્ક્રીન લૉકથી સુરક્ષિત રાખે છે. ચાલુ રાખવા પિન, પૅટર્ન, પાસવર્ડ કે બાયોમેટ્રિક સેટ કરો. સેટિંગ્સ ખોલો + પુનઃપ્રાપ્તિ પાસવર્ડ બદલો + વર્તમાન પાસવર્ડની પુષ્ટિ કરો + તમે જ છો તે સાબિત કરવા હાલનો પુનઃપ્રાપ્તિ પાસવર્ડ દાખલ કરો. + નવો પુનઃપ્રાપ્તિ પાસવર્ડ પસંદ કરો + આ તમારા જૂના પુનઃપ્રાપ્તિ પાસવર્ડની જગ્યા લે છે. તે રીસેટ થઈ શકતો નથી, સુરક્ષિત જગ્યાએ રાખો. + પુનઃપ્રાપ્તિ પાસવર્ડ બદલાયો. + એ પાસવર્ડ સાચો ન હતો. + પહેલાં બૅકઅપ લો + હમણાં મારી વૉલ્ટ નિકાસ કરો diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ae8560c..7f266ca 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -153,4 +153,13 @@ डिवाइस लॉक ज़रूरी है PassKey आपकी वॉल्ट को डिवाइस के स्क्रीन लॉक से सुरक्षित रखता है। जारी रखने के लिए पिन, पैटर्न, पासवर्ड या बायोमेट्रिक सेट करें। सेटिंग्स खोलें + पुनर्प्राप्ति पासवर्ड बदलें + मौजूदा पासवर्ड की पुष्टि करें + यह आप ही हैं, यह साबित करने के लिए मौजूदा पुनर्प्राप्ति पासवर्ड डालें। + नया पुनर्प्राप्ति पासवर्ड चुनें + यह आपके पुराने पुनर्प्राप्ति पासवर्ड की जगह लेगा। इसे रीसेट नहीं किया जा सकता, सुरक्षित जगह रखें। + पुनर्प्राप्ति पासवर्ड बदल दिया गया। + वह पासवर्ड सही नहीं था। + पहले बैकअप निर्यात करें + मेरी वॉल्ट अभी निर्यात करें \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 43bdc77..7658811 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -154,4 +154,13 @@ Blocco del dispositivo necessario PassKey protegge la cassaforte con il blocco schermo del dispositivo. Imposta PIN, sequenza, password o biometria per continuare. Apri impostazioni + Cambia password di recupero + Conferma la password attuale + Inserisci la password di recupero attuale per dimostrare che sei tu. + Scegli una nuova password di recupero + Sostituisce la tua vecchia password di recupero. Non può essere reimpostata, conservala al sicuro. + Password di recupero cambiata. + Quella password non era corretta. + Esporta prima un backup + Esporta la mia cassaforte ora diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1950b76..7f21b98 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -151,4 +151,13 @@ 端末ロックが必要です PassKey は端末の画面ロックで保管庫を保護します。続けるには PIN、パターン、パスワード、または生体認証を設定してください。 設定を開く + 復旧用パスワードを変更 + 現在のパスワードを確認 + 本人確認のため、現在の復旧用パスワードを入力してください。 + 新しい復旧用パスワードを設定 + 以前の復旧用パスワードと置き換わります。再発行はできませんので安全な場所に保管してください。 + 復旧用パスワードを変更しました。 + パスワードが正しくありませんでした。 + 先にバックアップを書き出す + 今すぐ保管庫を書き出す \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2d3fb9f..2c96486 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -152,4 +152,13 @@ 기기 잠금 필요 PassKey는 기기의 화면 잠금으로 보관함을 보호합니다. 계속하려면 PIN, 패턴, 비밀번호 또는 생체 인증을 설정하세요. 설정 열기 + 복구 비밀번호 변경 + 현재 비밀번호 확인 + 본인 확인을 위해 기존 복구 비밀번호를 입력하세요. + 새 복구 비밀번호 설정 + 기존 복구 비밀번호를 대체합니다. 재설정할 수 없으니 안전한 곳에 보관하세요. + 복구 비밀번호를 변경했습니다. + 비밀번호가 올바르지 않습니다. + 먼저 백업 내보내기 + 지금 보관함 내보내기 \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index a283bc4..535c44f 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -153,4 +153,13 @@ उपकरण लॉक आवश्यक PassKey तुमची व्हॉल्ट उपकरणाच्या स्क्रीन लॉकने सुरक्षित ठेवते. पुढे जाण्यासाठी पिन, पॅटर्न, पासवर्ड किंवा बायोमेट्रिक सेट करा. सेटिंग्ज उघडा + पुनर्प्राप्ती पासवर्ड बदला + सध्याच्या पासवर्डची पुष्टी करा + तुम्हीच आहात हे सिद्ध करण्यासाठी सध्याचा पुनर्प्राप्ती पासवर्ड टाका. + नवीन पुनर्प्राप्ती पासवर्ड निवडा + हा तुमच्या जुन्या पुनर्प्राप्ती पासवर्डची जागा घेतो. तो रीसेट करता येत नाही, सुरक्षित ठिकाणी ठेवा. + पुनर्प्राप्ती पासवर्ड बदलला. + तो पासवर्ड बरोबर नव्हता. + आधी बॅकअप घ्या + माझी व्हॉल्ट आता निर्यात करा \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f5e2b51..c5b4d16 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -154,4 +154,13 @@ Bloqueio de tela necessário O PassKey protege seu cofre com o bloqueio de tela do aparelho. Configure PIN, padrão, senha ou biometria para continuar. Abrir configurações + Alterar senha de recuperação + Confirme a senha atual + Digite sua senha de recuperação atual para provar que é você. + Escolha uma nova senha de recuperação + Ela substitui sua senha de recuperação antiga. Não pode ser redefinida, guarde-a em local seguro. + Senha de recuperação alterada. + Essa senha não estava correta. + Exportar um backup antes + Exportar meu cofre agora \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f071d1d..98caced 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -154,4 +154,13 @@ Нужна блокировка устройства PassKey защищает хранилище блокировкой экрана. Задайте PIN, графический ключ, пароль или биометрию, чтобы продолжить. Открыть настройки + Изменить пароль восстановления + Подтвердите текущий пароль + Введите нынешний пароль восстановления, чтобы подтвердить, что это вы. + Задайте новый пароль восстановления + Он заменит прежний пароль восстановления. Сбросить его нельзя, храните в надёжном месте. + Пароль восстановления изменён. + Этот пароль неверен. + Сначала выгрузить копию + Выгрузить хранилище сейчас diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index bc7149a..549c1d4 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -152,4 +152,13 @@ சாதனப் பூட்டு தேவை PassKey உங்கள் பெட்டகத்தை சாதனத்தின் திரைப் பூட்டால் பாதுகாக்கிறது. தொடர பின், வடிவம், கடவுச்சொல் அல்லது உயிரியல் அடையாளத்தை அமைக்கவும். அமைப்புகளைத் திற + மீட்பு கடவுச்சொல்லை மாற்று + தற்போதைய கடவுச்சொல்லை உறுதிப்படுத்து + நீங்கள்தான் என்பதை நிரூபிக்க தற்போதைய மீட்பு கடவுச்சொல்லை உள்ளிடவும். + புதிய மீட்பு கடவுச்சொல்லைத் தேர்ந்தெடு + இது உங்கள் பழைய மீட்பு கடவுச்சொல்லை மாற்றும். இதை மீட்டமைக்க முடியாது, பாதுகாப்பாக வைக்கவும். + மீட்பு கடவுச்சொல் மாற்றப்பட்டது. + அந்தக் கடவுச்சொல் சரியில்லை. + முதலில் காப்புப்பிரதி எடு + என் பெட்டகத்தை இப்போது ஏற்றுமதி செய் diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 6b84444..a9c6f73 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -152,5 +152,14 @@ పరికర లాక్ అవసరం PassKey మీ వాల్ట్‌ను పరికర స్క్రీన్ లాక్‌తో కాపాడుతుంది. కొనసాగించడానికి పిన్, నమూనా, పాస్‌వర్డ్ లేదా బయోమెట్రిక్ సెట్ చేయండి. సెట్టింగ్‌లు తెరువు + రికవరీ పాస్‌వర్డ్ మార్చు + ప్రస్తుత పాస్‌వర్డ్‌ను నిర్ధారించండి + మీరేనని నిరూపించడానికి ప్రస్తుత రికవరీ పాస్‌వర్డ్‌ను నమోదు చేయండి. + కొత్త రికవరీ పాస్‌వర్డ్ ఎంచుకోండి + ఇది మీ పాత రికవరీ పాస్‌వర్డ్‌ను భర్తీ చేస్తుంది. దీన్ని రీసెట్ చేయలేరు, సురక్షితంగా ఉంచండి. + రికవరీ పాస్‌వర్డ్ మార్చబడింది. + ఆ పాస్‌వర్డ్ సరైనది కాదు. + ముందు బ్యాకప్ తీసుకోండి + నా వాల్ట్‌ను ఇప్పుడే ఎగుమతి చేయి diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6ef165c..eb422ca 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -152,4 +152,13 @@ 需要设备锁 PassKey 使用设备的屏幕锁保护你的密码库。请设置 PIN、图案、密码或生物识别以继续。 打开设置 + 更改恢复密码 + 确认当前密码 + 请输入现有的恢复密码以证明是你本人。 + 设置新的恢复密码 + 这将替换你原来的恢复密码。它无法重置,请妥善保管。 + 恢复密码已更改。 + 该密码不正确。 + 先导出备份 + 立即导出我的密码库 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61832fe..329c0fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,4 +225,15 @@ Questions: contact yrkkh.cclub@gm Device lock required PassKey protects your vault with your device\'s screen lock. Set a PIN, pattern, password or biometric to continue. Open settings + + Change recovery password + Confirm current password + Enter your existing recovery password to prove it is you. + Choose a new recovery password + This replaces your old recovery password. It cannot be reset, so store it somewhere safe. + Recovery password changed. + That password was not correct. + + Export a backup first + Export my vault now \ No newline at end of file From 947a2bb07f8ce6cdd74d8d1e123db9e9c0b996ae Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 10:53:26 +0530 Subject: [PATCH 12/25] Fix the layering inversion and replace the bypassed preferences abstraction Mechanical moves and renames only - no behaviour change - so the diff reads as "things moved". Splitting it out keeps the MVI conversions that follow reviewable. The package layout had domain and data the wrong way round: the PasskeyRepository *interface* lived in data/ while its implementation lived in domain/, and all six ViewModels sat under domain/viewModels despite referencing navigation routes and UiText. The event types lived under domain/events while two of them imported androidx.compose.foundation.lazy.LazyListItemInfo, putting a Compose UI type in what was nominally the domain layer. Now: the repository interface is domain/repository, its implementation is data/repository, and each ViewModel and its event type sits beside the screen that uses it. DataStoreSource is replaced by a semantic PreferencesRepository. The old interface existed but was bypassed at the injection site - the concrete class was wired directly, so the abstraction bought nothing - and its generic Preferences.Key surface leaked DataStore into the ViewModels, which had to know about DataStoreRepository.onBoardingKey just to read a boolean. Callers now ask for onboardingCompleted, selectedLanguageTag and autoLockTimeout. While rewriting it, two defects went with it: - the preferencesDataStore delegate was a *member* extension property on the repository class. That delegate creates one instance per file and per-instance use risks "There are multiple DataStores active for the same file"; it worked only because the repository happened to be a singleton. It is now file-scoped. - SplashViewModel collected the onboarding flag with collect() on a DataStore flow, which never completes, so it re-sent a Navigate effect on every later preference change - including a language switch. Now first(). AutoLockTimeout moves to domain/model and loses its @StringRes label, which had domain depending on generated R. The UI maps values to text instead. File and class names that disagreed are aligned: PassKeyApplication.kt held PasskeyApplication, PasskeyDatabase.kt held PassKeyDatabase, PassKeyRepository.kt held PasskeyRepository, PreviewBottomNavigation.kt held MainBottomNavigation. The database rename needed a two-step git mv, being case-only on a case-insensitive filesystem, and Room's exported schema directory moved with it. The plan's flattening of presentation/screens/_screen to presentation/ is deliberately skipped: it is pure churn across ~30 files and the inversion was the actual defect. Still outstanding after this: the repository interface continues to speak in Room @Entity types, which is the next step. Co-Authored-By: Claude Opus 5 (1M context) --- .../1.json | 0 .../2.json | 0 .../3.json | 0 .../com/bhardwaj/passkey/MigrationTester.kt | 4 +- ...eyApplication.kt => PasskeyApplication.kt} | 0 .../passkey/data/backup/BackupRepository.kt | 2 +- .../datastore/PreferencesRepositoryImpl.kt | 67 +++++++++++++++++++ .../passkey/data/local/DataStoreSource.kt | 11 --- .../passkey/data/local/PasskeyDatabase.kt | 2 +- .../data/local/VaultDatabaseProvider.kt | 10 +-- .../data/repository/DataStoreRepository.kt | 55 --------------- .../repository/PasskeyRepositoryImpl.kt | 8 +-- .../passkey/data/security/AppLockObserver.kt | 12 ++-- .../passkey/data/security/AutoLockTimeout.kt | 23 ------- .../java/com/bhardwaj/passkey/di/AppModule.kt | 15 +++-- .../passkey/domain/model/AutoLockTimeout.kt | 26 +++++++ .../domain/{models => model}/Language.kt | 2 +- .../repository/PasskeyRepository.kt} | 2 +- .../repository/PreferencesRepository.kt | 23 +++++++ .../passkey/presentation/MainActivity.kt | 2 +- .../screens/detail_screen}/DetailEvents.kt | 2 +- .../screens/detail_screen/DetailPage.kt | 4 +- .../screens/detail_screen}/DetailViewModel.kt | 6 +- .../detail_screen/components/DetailsItem.kt | 2 +- .../onboarding_screens}/OnBoardingEvents.kt | 2 +- .../onboarding_screens/OnBoardingPage.kt | 4 +- .../OnBoardingViewModel.kt | 10 +-- .../screens/preview_screen}/PreviewEvents.kt | 2 +- .../screens/preview_screen/PreviewPage.kt | 4 +- .../preview_screen}/PreviewViewModel.kt | 6 +- ...mNavigation.kt => MainBottomNavigation.kt} | 0 .../preview_screen/components/PreviewItem.kt | 2 +- .../security_screen/VaultGateScreen.kt | 2 +- .../security_screen}/VaultGateViewModel.kt | 2 +- .../settings_screen}/SettingsEvents.kt | 6 +- .../screens/settings_screen/SettingsPage.kt | 5 +- .../settings_screen}/SettingsViewModel.kt | 31 +++------ .../components/AutoLockDialog.kt | 15 ++++- .../components/LanguageBottomSheet.kt | 2 +- .../screens/splash_screen}/SplashEvents.kt | 2 +- .../screens/splash_screen/SplashPage.kt | 4 +- .../screens/splash_screen}/SplashViewModel.kt | 28 ++++---- 42 files changed, 214 insertions(+), 191 deletions(-) rename app/schemas/{com.bhardwaj.passkey.data.local.PassKeyDatabase => com.bhardwaj.passkey.data.local.PasskeyDatabase}/1.json (100%) rename app/schemas/{com.bhardwaj.passkey.data.local.PassKeyDatabase => com.bhardwaj.passkey.data.local.PasskeyDatabase}/2.json (100%) rename app/schemas/{com.bhardwaj.passkey.data.local.PassKeyDatabase => com.bhardwaj.passkey.data.local.PasskeyDatabase}/3.json (100%) rename app/src/main/java/com/bhardwaj/passkey/{PassKeyApplication.kt => PasskeyApplication.kt} (100%) create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/datastore/PreferencesRepositoryImpl.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/data/local/DataStoreSource.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/data/repository/DataStoreRepository.kt rename app/src/main/java/com/bhardwaj/passkey/{domain => data}/repository/PasskeyRepositoryImpl.kt (93%) delete mode 100644 app/src/main/java/com/bhardwaj/passkey/data/security/AutoLockTimeout.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/model/AutoLockTimeout.kt rename app/src/main/java/com/bhardwaj/passkey/domain/{models => model}/Language.kt (77%) rename app/src/main/java/com/bhardwaj/passkey/{data/repository/PassKeyRepository.kt => domain/repository/PasskeyRepository.kt} (97%) create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/repository/PreferencesRepository.kt rename app/src/main/java/com/bhardwaj/passkey/{domain/events => presentation/screens/detail_screen}/DetailEvents.kt (95%) rename app/src/main/java/com/bhardwaj/passkey/{domain/viewModels => presentation/screens/detail_screen}/DetailViewModel.kt (98%) rename app/src/main/java/com/bhardwaj/passkey/{domain/events => presentation/screens/onboarding_screens}/OnBoardingEvents.kt (57%) rename app/src/main/java/com/bhardwaj/passkey/{domain/viewModels => presentation/screens/onboarding_screens}/OnBoardingViewModel.kt (71%) rename app/src/main/java/com/bhardwaj/passkey/{domain/events => presentation/screens/preview_screen}/PreviewEvents.kt (94%) rename app/src/main/java/com/bhardwaj/passkey/{domain/viewModels => presentation/screens/preview_screen}/PreviewViewModel.kt (97%) rename app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/{PreviewBottomNavigation.kt => MainBottomNavigation.kt} (100%) rename app/src/main/java/com/bhardwaj/passkey/{domain/viewModels => presentation/screens/security_screen}/VaultGateViewModel.kt (99%) rename app/src/main/java/com/bhardwaj/passkey/{domain/events => presentation/screens/settings_screen}/SettingsEvents.kt (94%) rename app/src/main/java/com/bhardwaj/passkey/{domain/viewModels => presentation/screens/settings_screen}/SettingsViewModel.kt (91%) rename app/src/main/java/com/bhardwaj/passkey/{domain/events => presentation/screens/splash_screen}/SplashEvents.kt (56%) rename app/src/main/java/com/bhardwaj/passkey/{domain/viewModels => presentation/screens/splash_screen}/SplashViewModel.kt (53%) diff --git a/app/schemas/com.bhardwaj.passkey.data.local.PassKeyDatabase/1.json b/app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/1.json similarity index 100% rename from app/schemas/com.bhardwaj.passkey.data.local.PassKeyDatabase/1.json rename to app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/1.json diff --git a/app/schemas/com.bhardwaj.passkey.data.local.PassKeyDatabase/2.json b/app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/2.json similarity index 100% rename from app/schemas/com.bhardwaj.passkey.data.local.PassKeyDatabase/2.json rename to app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/2.json diff --git a/app/schemas/com.bhardwaj.passkey.data.local.PassKeyDatabase/3.json b/app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/3.json similarity index 100% rename from app/schemas/com.bhardwaj.passkey.data.local.PassKeyDatabase/3.json rename to app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/3.json diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt b/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt index 5757029..93b7364 100644 --- a/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt +++ b/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt @@ -4,7 +4,7 @@ import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.bhardwaj.passkey.data.local.PassKeyDatabase +import com.bhardwaj.passkey.data.local.PasskeyDatabase import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_TABLE import com.bhardwaj.passkey.utils.MIGRATION_1_2 @@ -22,7 +22,7 @@ class MigrationTester { @get:Rule val helper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - PassKeyDatabase::class.java, + PasskeyDatabase::class.java, listOf(), FrameworkSQLiteOpenHelperFactory() ) diff --git a/app/src/main/java/com/bhardwaj/passkey/PassKeyApplication.kt b/app/src/main/java/com/bhardwaj/passkey/PasskeyApplication.kt similarity index 100% rename from app/src/main/java/com/bhardwaj/passkey/PassKeyApplication.kt rename to app/src/main/java/com/bhardwaj/passkey/PasskeyApplication.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt index a2eb905..0201b80 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt @@ -5,7 +5,7 @@ import android.net.Uri import com.bhardwaj.passkey.BuildConfig import com.bhardwaj.passkey.data.local.entity.Details import com.bhardwaj.passkey.data.local.entity.Preview -import com.bhardwaj.passkey.data.repository.PasskeyRepository +import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.utils.Categories import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/bhardwaj/passkey/data/datastore/PreferencesRepositoryImpl.kt b/app/src/main/java/com/bhardwaj/passkey/data/datastore/PreferencesRepositoryImpl.kt new file mode 100644 index 0000000..df44830 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/datastore/PreferencesRepositoryImpl.kt @@ -0,0 +1,67 @@ +package com.bhardwaj.passkey.data.datastore + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.bhardwaj.passkey.domain.model.AutoLockTimeout +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.utils.Constants.Companion.AUTO_LOCK_TIMEOUT +import com.bhardwaj.passkey.utils.Constants.Companion.CURRENT_LANGUAGE +import com.bhardwaj.passkey.utils.Constants.Companion.ONBOARDING_COMPLETE +import com.bhardwaj.passkey.utils.Constants.Companion.PASSKEY_PREFS +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The `preferencesDataStore` delegate is declared at file scope on purpose. + * + * It was previously a *member* extension property on the repository class. That delegate is + * documented as creating a single instance per file, and per-instance use risks + * "There are multiple DataStores active for the same file". It only worked because the + * repository happened to be a singleton. + */ +private val Context.dataStore by preferencesDataStore(PASSKEY_PREFS) + +@Singleton +class PreferencesRepositoryImpl @Inject constructor( + @param:ApplicationContext private val context: Context +) : PreferencesRepository { + + private val onboardingKey = booleanPreferencesKey(ONBOARDING_COMPLETE) + private val languageKey = stringPreferencesKey(CURRENT_LANGUAGE) + private val autoLockKey = longPreferencesKey(AUTO_LOCK_TIMEOUT) + + private val preferences: Flow = + context.dataStore.data.catch { error -> + if (error is IOException) emit(emptyPreferences()) else throw error + } + + override val onboardingCompleted: Flow = + preferences.map { it[onboardingKey] == true } + + override suspend fun setOnboardingCompleted(completed: Boolean) { + context.dataStore.edit { it[onboardingKey] = completed } + } + + override val selectedLanguageTag: Flow = preferences.map { it[languageKey] } + + override suspend fun setSelectedLanguageTag(tag: String) { + context.dataStore.edit { it[languageKey] = tag } + } + + override val autoLockTimeout: Flow = + preferences.map { AutoLockTimeout.fromMillis(it[autoLockKey]) } + + override suspend fun setAutoLockTimeout(timeout: AutoLockTimeout) { + context.dataStore.edit { it[autoLockKey] = timeout.millis } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/DataStoreSource.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/DataStoreSource.kt deleted file mode 100644 index 0002e74..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/DataStoreSource.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.bhardwaj.passkey.data.local - -import androidx.datastore.preferences.core.Preferences -import kotlinx.coroutines.flow.Flow - -interface DataStoreSource { - suspend fun readPreference(key: Preferences.Key, defaultValue: T): Flow - suspend fun savePreference(key: Preferences.Key, value: T) - suspend fun removePreference(key: Preferences.Key) - suspend fun clearAllPreference() -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt index 72397c3..c0c52fd 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt @@ -12,7 +12,7 @@ import com.bhardwaj.passkey.data.local.entity.Preview version = 3, exportSchema = true ) -abstract class PassKeyDatabase : RoomDatabase() { +abstract class PasskeyDatabase : RoomDatabase() { abstract val previewDao: PreviewDao abstract val detailsDao: DetailsDao } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt index 8f66eff..8adaf31 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt @@ -32,21 +32,21 @@ class VaultDatabaseProvider @Inject constructor( ) { private val mutex = Mutex() - private val _database = MutableStateFlow(null) - val database: StateFlow = _database.asStateFlow() + private val _database = MutableStateFlow(null) + val database: StateFlow = _database.asStateFlow() /** Kept so it can be zeroed on lock; see [closeAndWipe]. */ private var keyBytes: ByteArray? = null val isOpen: Boolean get() = _database.value != null - suspend fun open(dek: ByteArray): PassKeyDatabase = mutex.withLock { + suspend fun open(dek: ByteArray): PasskeyDatabase = mutex.withLock { _database.value?.let { return@withLock it } withContext(Dispatchers.IO) { val key = dek.toSqlCipherRawKey() val db = Room.databaseBuilder( context = application, - klass = PassKeyDatabase::class.java, + klass = PasskeyDatabase::class.java, name = Constants.PASS_KEY_DATABASE ) .addMigrations(MIGRATION_1_2, MIGRATION_2_3) @@ -64,7 +64,7 @@ class VaultDatabaseProvider @Inject constructor( } /** Suspends until the vault is unlocked. */ - suspend fun requireDb(): PassKeyDatabase = _database.filterNotNull().first() + suspend fun requireDb(): PasskeyDatabase = _database.filterNotNull().first() suspend fun closeAndWipe() = mutex.withLock { withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/bhardwaj/passkey/data/repository/DataStoreRepository.kt b/app/src/main/java/com/bhardwaj/passkey/data/repository/DataStoreRepository.kt deleted file mode 100644 index da1fdfd..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/data/repository/DataStoreRepository.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.bhardwaj.passkey.data.repository - -import android.content.Context -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.bhardwaj.passkey.data.local.DataStoreSource -import com.bhardwaj.passkey.utils.Constants.Companion.AUTO_LOCK_TIMEOUT -import com.bhardwaj.passkey.utils.Constants.Companion.CURRENT_LANGUAGE -import com.bhardwaj.passkey.utils.Constants.Companion.ONBOARDING_COMPLETE -import com.bhardwaj.passkey.utils.Constants.Companion.PASSKEY_PREFS -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import java.io.IOException -import javax.inject.Inject - -class DataStoreRepository @Inject constructor(private val context: Context) : DataStoreSource { - companion object { - val onBoardingKey = booleanPreferencesKey(name = ONBOARDING_COMPLETE) - val currentLanguageKey = stringPreferencesKey(name = CURRENT_LANGUAGE) - val autoLockTimeoutKey = longPreferencesKey(name = AUTO_LOCK_TIMEOUT) - } - - private val Context.dataStore by preferencesDataStore(PASSKEY_PREFS) - private val dataSource = context.dataStore - - override suspend fun readPreference(key: Preferences.Key, defaultValue: T): Flow = - dataSource.data.catch { exception -> - if (exception is IOException) { - emit(emptyPreferences()) - } else { - throw exception - } - }.map { preferences -> - val result = preferences[key] ?: defaultValue - result - } - - override suspend fun savePreference(key: Preferences.Key, value: T) { - context.dataStore.edit { preferences -> preferences[key] = value } - } - - override suspend fun removePreference(key: Preferences.Key) { - dataSource.edit { it.remove(key) } - } - - override suspend fun clearAllPreference() { - dataSource.edit { preferences -> preferences.clear() } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt b/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt similarity index 93% rename from app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt rename to app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt index fd9b9a6..fa68ea1 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepositoryImpl.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt @@ -1,16 +1,16 @@ -package com.bhardwaj.passkey.domain.repository +package com.bhardwaj.passkey.data.repository import androidx.room.withTransaction import com.bhardwaj.passkey.data.local.VaultDatabaseProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import com.bhardwaj.passkey.data.local.PassKeyDatabase +import com.bhardwaj.passkey.data.local.PasskeyDatabase import com.bhardwaj.passkey.data.local.dao.DetailsDao import com.bhardwaj.passkey.data.local.dao.PreviewDao import com.bhardwaj.passkey.data.local.entity.Details import com.bhardwaj.passkey.data.local.entity.Preview -import com.bhardwaj.passkey.data.repository.PasskeyRepository +import com.bhardwaj.passkey.domain.repository.PasskeyRepository import kotlinx.coroutines.flow.Flow @OptIn(ExperimentalCoroutinesApi::class) @@ -29,7 +29,7 @@ class PasskeyRepositoryImpl( * leave collectors on a closed database and throw * "attempt to re-open an already-closed object" on the next emission. */ - private fun vaultFlow(block: (PassKeyDatabase) -> Flow>): Flow> = + private fun vaultFlow(block: (PasskeyDatabase) -> Flow>): Flow> = vault.database.flatMapLatest { db -> db?.let(block) ?: flowOf(emptyList()) } override suspend fun runInTransaction(block: suspend () -> R): R = diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt index 9ed4e91..dd33985 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/security/AppLockObserver.kt @@ -1,10 +1,11 @@ package com.bhardwaj.passkey.data.security +import com.bhardwaj.passkey.domain.model.AutoLockTimeout import android.content.Context import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.bhardwaj.passkey.data.local.VaultDatabaseProvider -import com.bhardwaj.passkey.data.repository.DataStoreRepository +import com.bhardwaj.passkey.domain.repository.PreferencesRepository import com.bhardwaj.passkey.utils.SecureClipboard import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -26,7 +27,7 @@ class AppLockObserver @Inject constructor( @param:ApplicationContext private val context: Context, private val session: VaultSession, private val vault: VaultDatabaseProvider, - private val dataStoreRepository: DataStoreRepository + private val preferences: PreferencesRepository ) : DefaultLifecycleObserver { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -71,10 +72,5 @@ class AppLockObserver @Inject constructor( } } - suspend fun timeout(): AutoLockTimeout = AutoLockTimeout.fromMillis( - dataStoreRepository.readPreference( - key = DataStoreRepository.autoLockTimeoutKey, - defaultValue = AutoLockTimeout.DEFAULT.millis - ).first() - ) + suspend fun timeout(): AutoLockTimeout = preferences.autoLockTimeout.first() } diff --git a/app/src/main/java/com/bhardwaj/passkey/data/security/AutoLockTimeout.kt b/app/src/main/java/com/bhardwaj/passkey/data/security/AutoLockTimeout.kt deleted file mode 100644 index 19db70b..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/data/security/AutoLockTimeout.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.bhardwaj.passkey.data.security - -import androidx.annotation.StringRes -import com.bhardwaj.passkey.R - -/** User-selectable idle period before the vault re-locks. */ -enum class AutoLockTimeout(val millis: Long, @param:StringRes val labelRes: Int) { - IMMEDIATELY(0L, R.string.auto_lock_immediately), - SECONDS_15(15_000L, R.string.auto_lock_15_seconds), - SECONDS_30(30_000L, R.string.auto_lock_30_seconds), - MINUTE_1(60_000L, R.string.auto_lock_1_minute), - MINUTES_5(300_000L, R.string.auto_lock_5_minutes), - - /** Deliberately last, and confirmed before it can be chosen. */ - NEVER(Long.MAX_VALUE, R.string.auto_lock_never); - - companion object { - val DEFAULT = SECONDS_30 - - fun fromMillis(millis: Long?): AutoLockTimeout = - entries.firstOrNull { it.millis == millis } ?: DEFAULT - } -} diff --git a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt index c255e40..b4d130a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt +++ b/app/src/main/java/com/bhardwaj/passkey/di/AppModule.kt @@ -2,11 +2,12 @@ package com.bhardwaj.passkey.di import android.content.Context import com.bhardwaj.passkey.data.local.VaultDatabaseProvider -import com.bhardwaj.passkey.data.repository.DataStoreRepository -import com.bhardwaj.passkey.data.repository.PasskeyRepository +import com.bhardwaj.passkey.data.datastore.PreferencesRepositoryImpl +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.data.security.DefaultKeyDerivation import com.bhardwaj.passkey.data.security.KeyDerivation -import com.bhardwaj.passkey.domain.repository.PasskeyRepositoryImpl +import com.bhardwaj.passkey.data.repository.PasskeyRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,7 +19,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object AppModule { - // PassKeyDatabase is deliberately NOT provided here any more. It can only be built once a + // PasskeyDatabase is deliberately NOT provided here any more. It can only be built once a // data encryption key exists, which requires the user to have authenticated, so it is owned // by VaultDatabaseProvider and opened on unlock. @@ -33,7 +34,7 @@ object AppModule { @Provides @Singleton - fun provideDataStoreRepository( - @ApplicationContext context: Context - ) = DataStoreRepository(context = context) + fun providePreferencesRepository( + impl: PreferencesRepositoryImpl + ): PreferencesRepository = impl } diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/AutoLockTimeout.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/AutoLockTimeout.kt new file mode 100644 index 0000000..7cfd076 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/AutoLockTimeout.kt @@ -0,0 +1,26 @@ +package com.bhardwaj.passkey.domain.model + +/** + * User-selectable idle period before the vault re-locks. + * + * Carries no string resource: a display label is an Android concern, and having one here made + * domain depend on generated R, which is the layering this release is untangling. The UI maps + * each value to text. + */ +enum class AutoLockTimeout(val millis: Long) { + IMMEDIATELY(0L), + SECONDS_15(15_000L), + SECONDS_30(30_000L), + MINUTE_1(60_000L), + MINUTES_5(300_000L), + + /** Deliberately last, and confirmed before it can be chosen. */ + NEVER(Long.MAX_VALUE); + + companion object { + val DEFAULT = SECONDS_30 + + fun fromMillis(millis: Long?): AutoLockTimeout = + entries.firstOrNull { it.millis == millis } ?: DEFAULT + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/models/Language.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt similarity index 77% rename from app/src/main/java/com/bhardwaj/passkey/domain/models/Language.kt rename to app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt index f358f5c..7b1ea6d 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/models/Language.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.models +package com.bhardwaj.passkey.domain.model data class Language( val languageId: String, diff --git a/app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt similarity index 97% rename from app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt rename to app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt index 8be27f4..bf32daf 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/repository/PassKeyRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.data.repository +package com.bhardwaj.passkey.domain.repository import com.bhardwaj.passkey.data.local.entity.Details import com.bhardwaj.passkey.data.local.entity.Preview diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PreferencesRepository.kt b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PreferencesRepository.kt new file mode 100644 index 0000000..7a2f859 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PreferencesRepository.kt @@ -0,0 +1,23 @@ +package com.bhardwaj.passkey.domain.repository + +import com.bhardwaj.passkey.domain.model.AutoLockTimeout +import kotlinx.coroutines.flow.Flow + +/** + * App preferences, expressed in the app's own terms. + * + * Replaces the generic DataStoreSource, whose `Preferences.Key` surface leaked DataStore all + * the way into the ViewModels - callers had to know about `DataStoreRepository.onBoardingKey` to + * read a boolean. That interface also existed but was bypassed at the injection site, so the + * concrete class was wired directly and the abstraction bought nothing. + */ +interface PreferencesRepository { + val onboardingCompleted: Flow + suspend fun setOnboardingCompleted(completed: Boolean) + + val selectedLanguageTag: Flow + suspend fun setSelectedLanguageTag(tag: String) + + val autoLockTimeout: Flow + suspend fun setAutoLockTimeout(timeout: AutoLockTimeout) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt index 3bd67d8..50af90d 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt @@ -20,7 +20,7 @@ import com.bhardwaj.passkey.data.security.LockReason import com.bhardwaj.passkey.presentation.navigation.Routes import kotlinx.coroutines.delay import com.bhardwaj.passkey.data.security.VaultSession -import com.bhardwaj.passkey.domain.viewModels.SplashViewModel +import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashViewModel import javax.inject.Inject import com.bhardwaj.passkey.presentation.navigation.NavGraph import com.bhardwaj.passkey.presentation.theme.PassKeyTheme diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/DetailEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt similarity index 95% rename from app/src/main/java/com/bhardwaj/passkey/domain/events/DetailEvents.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt index 180739e..502a654 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/DetailEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.events +package com.bhardwaj.passkey.presentation.screens.detail_screen import androidx.compose.foundation.lazy.LazyListItemInfo import com.bhardwaj.passkey.data.local.entity.Details diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 5b2ea51..896315c 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.DetailEvents -import com.bhardwaj.passkey.domain.viewModels.DetailViewModel +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailViewModel import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PasskeySearchBar import com.bhardwaj.passkey.presentation.screens.detail_screen.components.DetailsBottomSheet diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt similarity index 98% rename from app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt index 547920a..cc7ea03 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.viewModels +package com.bhardwaj.passkey.presentation.screens.detail_screen import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -9,8 +9,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.R import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.data.repository.PasskeyRepository -import com.bhardwaj.passkey.domain.events.DetailEvents +import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING import com.bhardwaj.passkey.utils.PasswordGenerator import com.bhardwaj.passkey.utils.UiEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt index f7b6ff6..06d3f25 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.domain.events.DetailEvents +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins import sh.calvin.reorderable.ReorderableCollectionItemScope diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/OnBoardingEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt similarity index 57% rename from app/src/main/java/com/bhardwaj/passkey/domain/events/OnBoardingEvents.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt index 4270851..e3632fd 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/OnBoardingEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.events +package com.bhardwaj.passkey.presentation.screens.onboarding_screens sealed interface OnBoardingEvents { data object OnBoardingComplete : OnBoardingEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt index e5b540b..ae281fe 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.OnBoardingEvents -import com.bhardwaj.passkey.domain.viewModels.OnBoardingViewModel +import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingEvents +import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingViewModel import com.bhardwaj.passkey.presentation.screens.onboarding_screens.components.OnBoardingItem import com.bhardwaj.passkey.presentation.screens.onboarding_screens.components.OnBoardingPageIndicator import com.bhardwaj.passkey.utils.UiEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/OnBoardingViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt similarity index 71% rename from app/src/main/java/com/bhardwaj/passkey/domain/viewModels/OnBoardingViewModel.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt index 29fa9f5..7cf98c3 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/OnBoardingViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt @@ -1,9 +1,9 @@ -package com.bhardwaj.passkey.domain.viewModels +package com.bhardwaj.passkey.presentation.screens.onboarding_screens import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.bhardwaj.passkey.data.repository.DataStoreRepository -import com.bhardwaj.passkey.domain.events.OnBoardingEvents +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingEvents import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.utils.UiEvents import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,7 +14,7 @@ import javax.inject.Inject @HiltViewModel class OnBoardingViewModel @Inject constructor( - private val repository: DataStoreRepository + private val preferences: PreferencesRepository ) : ViewModel() { private val _uiEvents = Channel() @@ -24,7 +24,7 @@ class OnBoardingViewModel @Inject constructor( when (event) { is OnBoardingEvents.OnBoardingComplete -> { viewModelScope.launch { - repository.savePreference(key = DataStoreRepository.onBoardingKey, value = true) + preferences.setOnboardingCompleted(true) _uiEvents.send(UiEvents.Navigate(Routes.SECURITY_PAGE)) } } diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/PreviewEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt similarity index 94% rename from app/src/main/java/com/bhardwaj/passkey/domain/events/PreviewEvents.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt index be431d3..fe6e2eb 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/PreviewEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.events +package com.bhardwaj.passkey.presentation.screens.preview_screen import androidx.compose.foundation.lazy.LazyListItemInfo import com.bhardwaj.passkey.data.local.entity.Preview diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index 3f0ec41..19fd204 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -59,8 +59,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.PreviewEvents -import com.bhardwaj.passkey.domain.viewModels.PreviewViewModel +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewViewModel import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PasskeySearchBar import com.bhardwaj.passkey.presentation.screens.preview_screen.components.MainBottomNavigation diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt similarity index 97% rename from app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt index 90a35bf..f72eefb 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.viewModels +package com.bhardwaj.passkey.presentation.screens.preview_screen import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -8,8 +8,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.R import com.bhardwaj.passkey.data.local.entity.Preview -import com.bhardwaj.passkey.data.repository.PasskeyRepository -import com.bhardwaj.passkey.domain.events.PreviewEvents +import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.utils.Categories import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomNavigation.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/MainBottomNavigation.kt similarity index 100% rename from app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomNavigation.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/MainBottomNavigation.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt index 4e6b710..218bd33 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.PreviewEvents +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.theme.Poppins import sh.calvin.reorderable.ReorderableCollectionItemScope import com.bhardwaj.passkey.data.local.entity.Preview as PreviewEntity diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt index 55a080b..c5c42ee 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateScreen.kt @@ -35,7 +35,7 @@ import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.viewModels.VaultGateViewModel +import com.bhardwaj.passkey.presentation.screens.security_screen.VaultGateViewModel import com.bhardwaj.passkey.presentation.screens.settings_screen.components.BackupPasswordDialog /** diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateViewModel.kt similarity index 99% rename from app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateViewModel.kt index c4312a3..fe16fc6 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/VaultGateViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/security_screen/VaultGateViewModel.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.viewModels +package com.bhardwaj.passkey.presentation.screens.security_screen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt similarity index 94% rename from app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt index 39fafcc..e166701 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/SettingsEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt @@ -1,10 +1,10 @@ -package com.bhardwaj.passkey.domain.events +package com.bhardwaj.passkey.presentation.screens.settings_screen import android.net.Uri import com.bhardwaj.passkey.data.backup.ImportMode -import com.bhardwaj.passkey.data.security.AutoLockTimeout +import com.bhardwaj.passkey.domain.model.AutoLockTimeout import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.domain.models.Language +import com.bhardwaj.passkey.domain.model.Language sealed interface SettingsEvents { data class OnLanguageChange(val newLanguage: Language) : SettingsEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index 5fb5a24..f36ee03 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen +import com.bhardwaj.passkey.domain.model.AutoLockTimeout import android.content.ContentResolver import android.content.Context import android.net.Uri @@ -53,8 +54,8 @@ import java.util.Locale import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bhardwaj.passkey.presentation.screens.settings_screen.components.AutoLockDialog import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.SettingsEvents -import com.bhardwaj.passkey.domain.viewModels.SettingsViewModel +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEvents +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsViewModel import com.bhardwaj.passkey.presentation.screens.settings_screen.components.AnalysisBottomSheet import com.bhardwaj.passkey.presentation.screens.settings_screen.components.FaqItem import com.bhardwaj.passkey.presentation.screens.settings_screen.components.LanguageBottomSheet diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt similarity index 91% rename from app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index 5d132f2..d264a30 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.viewModels +package com.bhardwaj.passkey.presentation.screens.settings_screen import android.app.Application import android.app.LocaleManager @@ -17,11 +17,11 @@ import com.bhardwaj.passkey.R import com.bhardwaj.passkey.data.backup.BackupError import com.bhardwaj.passkey.data.backup.BackupException import com.bhardwaj.passkey.data.backup.BackupRepository -import com.bhardwaj.passkey.data.security.AutoLockTimeout +import com.bhardwaj.passkey.domain.model.AutoLockTimeout import com.bhardwaj.passkey.data.security.DatabaseKeyManager -import com.bhardwaj.passkey.data.repository.DataStoreRepository -import com.bhardwaj.passkey.data.repository.PasskeyRepository -import com.bhardwaj.passkey.domain.events.SettingsEvents +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEvents import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.utils.AlertBy.ABOUT import com.bhardwaj.passkey.utils.AlertBy.PRIVACY @@ -49,7 +49,7 @@ import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val repository: PasskeyRepository, - private val dataStoreRepository: DataStoreRepository, + private val preferences: PreferencesRepository, private val backupRepository: BackupRepository, private val keyManager: DatabaseKeyManager, private val appContext: Application @@ -87,14 +87,7 @@ class SettingsViewModel @Inject constructor( /** Held only between the two dialog steps, then zeroed. */ private var pendingCurrentRecoveryPassword: CharArray? = null - val autoLockTimeout: StateFlow = flow { - emitAll( - dataStoreRepository.readPreference( - key = DataStoreRepository.autoLockTimeoutKey, - defaultValue = AutoLockTimeout.DEFAULT.millis - ) - ) - }.map { AutoLockTimeout.fromMillis(it) } + val autoLockTimeout: StateFlow = preferences.autoLockTimeout .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AutoLockTimeout.DEFAULT) fun onEvent(event: SettingsEvents) { @@ -140,10 +133,7 @@ class SettingsViewModel @Inject constructor( ) } else { viewModelScope.launch { - dataStoreRepository.savePreference( - key = DataStoreRepository.currentLanguageKey, - value = event.newLanguage.languageId - ) + preferences.setSelectedLanguageTag(event.newLanguage.languageId) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { appContext.getSystemService(LocaleManager::class.java).applicationLocales = LocaleList.forLanguageTags(event.newLanguage.languageId) @@ -259,10 +249,7 @@ class SettingsViewModel @Inject constructor( is SettingsEvents.OnAutoLockTimeoutChange -> { isAutoLockDialogOpen = false viewModelScope.launch { - dataStoreRepository.savePreference( - key = DataStoreRepository.autoLockTimeoutKey, - value = event.timeout.millis - ) + preferences.setAutoLockTimeout(event.timeout) } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AutoLockDialog.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AutoLockDialog.kt index 4e6c631..100c3fe 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AutoLockDialog.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AutoLockDialog.kt @@ -22,7 +22,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.SecureFlagPolicy import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.data.security.AutoLockTimeout +import com.bhardwaj.passkey.domain.model.AutoLockTimeout + +/** Display label for a timeout. Lives here because text is a presentation concern. */ +@androidx.annotation.StringRes +private fun AutoLockTimeout.labelRes(): Int = when (this) { + AutoLockTimeout.IMMEDIATELY -> R.string.auto_lock_immediately + AutoLockTimeout.SECONDS_15 -> R.string.auto_lock_15_seconds + AutoLockTimeout.SECONDS_30 -> R.string.auto_lock_30_seconds + AutoLockTimeout.MINUTE_1 -> R.string.auto_lock_1_minute + AutoLockTimeout.MINUTES_5 -> R.string.auto_lock_5_minutes + AutoLockTimeout.NEVER -> R.string.auto_lock_never +} @Composable fun AutoLockDialog( @@ -52,7 +63,7 @@ fun AutoLockDialog( ) Text( modifier = Modifier.padding(start = 8.dp), - text = stringResource(option.labelRes) + text = stringResource(option.labelRes()) ) } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt index 26eebc8..9dafd94 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.models.Language +import com.bhardwaj.passkey.domain.model.Language import com.bhardwaj.passkey.presentation.theme.Poppins @Composable diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/events/SplashEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt similarity index 56% rename from app/src/main/java/com/bhardwaj/passkey/domain/events/SplashEvents.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt index 6f05994..8af7cd2 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/events/SplashEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt @@ -1,4 +1,4 @@ -package com.bhardwaj.passkey.domain.events +package com.bhardwaj.passkey.presentation.screens.splash_screen sealed interface SplashEvents { data object OnLoadingComplete : SplashEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt index af58f46..6279938 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.events.SplashEvents -import com.bhardwaj.passkey.domain.viewModels.SplashViewModel +import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashEvents +import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashViewModel import com.bhardwaj.passkey.presentation.theme.Poppins import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SplashViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt similarity index 53% rename from app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SplashViewModel.kt rename to app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt index b86d979..4b44948 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/viewModels/SplashViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt @@ -1,12 +1,13 @@ -package com.bhardwaj.passkey.domain.viewModels +package com.bhardwaj.passkey.presentation.screens.splash_screen import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.bhardwaj.passkey.data.repository.DataStoreRepository -import com.bhardwaj.passkey.domain.events.SplashEvents +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import kotlinx.coroutines.flow.first +import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashEvents import com.bhardwaj.passkey.presentation.navigation.NavScreens import com.bhardwaj.passkey.utils.UiEvents import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,7 +18,7 @@ import javax.inject.Inject @HiltViewModel class SplashViewModel @Inject constructor( - val repository: DataStoreRepository + private val preferences: PreferencesRepository ) : ViewModel() { var startDestination by mutableStateOf(NavScreens.SplashPage.route) @@ -30,17 +31,16 @@ class SplashViewModel @Inject constructor( when (event) { SplashEvents.OnLoadingComplete -> { viewModelScope.launch { - repository.readPreference( - DataStoreRepository.onBoardingKey, - defaultValue = false - ).collect { completed -> - startDestination = if (completed) { - NavScreens.SecurityPage.route - } else { - NavScreens.OnboardingPage.route - } - _uiEvents.send(UiEvents.Navigate(startDestination)) + // first(), not collect(): a DataStore flow never completes, so collecting + // it re-sent a Navigate effect on every later preference change - including a + // language switch. + val completed = preferences.onboardingCompleted.first() + startDestination = if (completed) { + NavScreens.SecurityPage.route + } else { + NavScreens.OnboardingPage.route } + _uiEvents.send(UiEvents.Navigate(startDestination)) } } } From 4d7133c82d3c88fcdbc1f818689956de53d44019 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 11:02:56 +0530 Subject: [PATCH 13/25] Introduce domain models so Room entities stop crossing layers The repository interface spoke in Room @Entity types, so every ViewModel and screen handled database rows directly - and handled their nullable primary keys, which is why `previewId!!` and `detailsId!!` appeared throughout. Room entities are renamed PreviewEntity/DetailsEntity and confined to the data layer. New domain Preview and Detail carry non-null ids. Crucially the *entities* are left alone: making their ids non-null would change the column's notNull flag, change Room's identity hash, and require a migration for no functional gain. The mappers assert instead, which is always safe because Room populates the primary key on every read. All 21 instrumented tests still pass, confirming the rename did not disturb the exported schema - Room keys it off table and column names, not the class name. The repository also gains intent-revealing methods. createPreview/createDetail return the new row id, so callers no longer construct a half-built entity with a null id and hope; updatePreview/updateDetail take a domain object that already has one. Categories is consolidated into domain Category. There were briefly two of them after the rename, and the old one carried a @StringRes label that had domain depending on generated R. Labels now map to text in the presentation layer, same as AutoLockTimeout. Category.fromNameOrOther replaces the ad-hoc helper in the backup code - never valueOf, which throws on the untrusted contents of an imported file. Two of the edge cases the audit listed are fixed here because the change opened their files anyway: - duplicate detection was case-sensitive, so "Gmail" and "gmail" coexisted as separate entries and re-importing a differently-cased backup silently created duplicates. The lookup now uses COLLATE NOCASE. - previews are filtered by category in SQL via getPreviewsByCategory, rather than in the ViewModel. Reordering used to renumber a list already narrowed by both category and search text while the query ordered globally, so sequences collided across categories and ordering was quietly unstable. Also switched the preview list from SharingStarted.Lazily to WhileSubscribed(5s). Lazily keeps Room's invalidation observer alive for the ViewModel's whole life even while the screen is backgrounded. Domain now imports nothing but its own types and kotlinx coroutines - no Android, no Compose, no Room. Co-Authored-By: Claude Opus 5 (1M context) --- .../passkey/data/backup/BackupPayload.kt | 2 +- .../passkey/data/backup/BackupRepository.kt | 41 +++-- .../passkey/data/local/PasskeyDatabase.kt | 6 +- .../passkey/data/local/dao/DetailsDao.kt | 14 +- .../passkey/data/local/dao/PreviewDao.kt | 24 ++- .../entity/{Details.kt => DetailsEntity.kt} | 2 +- .../entity/{Preview.kt => PreviewEntity.kt} | 6 +- .../passkey/data/mapper/VaultMappers.kt | 41 +++++ .../data/repository/PasskeyRepositoryImpl.kt | 140 ++++++++++-------- .../bhardwaj/passkey/domain/model/Vault.kt | 49 ++++++ .../domain/repository/PasskeyRepository.kt | 64 +++++--- .../presentation/navigation/NavGraph.kt | 4 +- .../screens/common/CategoryLabel.kt | 21 +++ .../screens/detail_screen/DetailEvents.kt | 6 +- .../screens/detail_screen/DetailPage.kt | 4 +- .../screens/detail_screen/DetailViewModel.kt | 60 ++++---- .../detail_screen/components/DetailsItem.kt | 4 +- .../screens/preview_screen/PreviewEvents.kt | 2 +- .../screens/preview_screen/PreviewPage.kt | 11 +- .../preview_screen/PreviewViewModel.kt | 61 ++++---- .../components/MainBottomNavigation.kt | 17 ++- .../preview_screen/components/PreviewItem.kt | 4 +- .../screens/settings_screen/SettingsEvents.kt | 4 +- .../settings_screen/SettingsViewModel.kt | 2 +- .../components/AnalysisBottomSheet.kt | 10 +- .../com/bhardwaj/passkey/utils/Categories.kt | 15 -- .../bhardwaj/passkey/utils/Migration_2_3.kt | 2 +- .../passkey/utils/PasswordAnalyser.kt | 8 +- .../bhardwaj/passkey/PasswordAnalyzerTest.kt | 8 +- 29 files changed, 385 insertions(+), 247 deletions(-) rename app/src/main/java/com/bhardwaj/passkey/data/local/entity/{Details.kt => DetailsEntity.kt} (96%) rename app/src/main/java/com/bhardwaj/passkey/data/local/entity/{Preview.kt => PreviewEntity.kt} (76%) create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/model/Vault.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/CategoryLabel.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt index 8890797..10501ed 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable /** * The decrypted contents of a backup. * - * Details are nested under their preview rather than carrying `previewId`, so no install-local + * Detail are nested under their preview rather than carrying `previewId`, so no install-local * row id crosses the file boundary and a restore into a different install cannot collide. */ @Serializable diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt index 0201b80..a0ca357 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt @@ -3,10 +3,10 @@ package com.bhardwaj.passkey.data.backup import android.content.Context import android.net.Uri import com.bhardwaj.passkey.BuildConfig -import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.data.local.entity.Preview +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview import com.bhardwaj.passkey.domain.repository.PasskeyRepository -import com.bhardwaj.passkey.utils.Categories import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -56,9 +56,9 @@ class BackupRepository @Inject constructor( repository.getPreviews().first().map { preview -> BackupPreview( heading = preview.heading, - categoryName = preview.categoryName.name, + categoryName = preview.category.name, sequence = preview.sequence, - details = repository.getDetailsByPreviewId(preview.previewId!!).first() + details = repository.getDetailsByPreviewId(preview.id).first() .map { BackupDetail(it.question, it.answer, it.sequence) } ) } @@ -177,14 +177,12 @@ class BackupRepository @Inject constructor( skipped++ return@forEach } - val category = backupPreview.categoryName.toCategoryOrOther() - val existing = repository.getPreviewByHeading(heading, category.name) - val previewId = existing?.previewId ?: repository.upsertPreview( - Preview( - heading = heading, - categoryName = category, - sequence = backupPreview.sequence - ) + val category = Category.fromNameOrOther(backupPreview.categoryName) + val existing = repository.getPreviewByHeading(heading, category) + val previewId = existing?.id ?: repository.createPreview( + heading = heading, + category = category, + sequence = backupPreview.sequence ).also { previewsAdded++ } backupPreview.details.forEach { detail -> @@ -198,13 +196,11 @@ class BackupRepository @Inject constructor( return@forEach } if (repository.getDetailByContent(previewId, question, answer) == null) { - repository.upsertDetails( - Details( - previewId = previewId, - question = question, - answer = answer, - sequence = detail.sequence - ) + repository.createDetail( + previewId = previewId, + question = question, + answer = answer, + sequence = detail.sequence ) detailsAdded++ } @@ -251,9 +247,6 @@ class BackupRepository @Inject constructor( class BackupException(val error: BackupError) : Exception(error.toString()) /** - * Never [Categories.valueOf]: it throws on unknown input, and the previous importer let that + * Never [Category.valueOf]: it throws on unknown input, and the previous importer let that * escape into a broad catch that silently abandoned the rest of the file. */ -internal fun String.toCategoryOrOther(): Categories = - Categories.entries.firstOrNull { it.name.equals(this.trim(), ignoreCase = true) } - ?: Categories.OTHERS diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt index c0c52fd..67fccea 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt @@ -4,11 +4,11 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.bhardwaj.passkey.data.local.dao.DetailsDao import com.bhardwaj.passkey.data.local.dao.PreviewDao -import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.data.local.entity.Preview +import com.bhardwaj.passkey.data.local.entity.DetailsEntity +import com.bhardwaj.passkey.data.local.entity.PreviewEntity @Database( - entities = [Preview::class, Details::class], + entities = [PreviewEntity::class, DetailsEntity::class], version = 3, exportSchema = true ) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt index 4e22df6..04daa2a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailsDao.kt @@ -4,29 +4,29 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.data.local.entity.DetailsEntity import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE import kotlinx.coroutines.flow.Flow @Dao interface DetailsDao { @Query("SELECT * FROM $DETAILS_TABLE ORDER BY sequence ASC") - fun getDetails(): Flow> + fun getDetails(): Flow> @Query("SELECT * FROM $DETAILS_TABLE WHERE previewId=:previewId ORDER BY sequence ASC") - fun getDetailsByPreviewId(previewId: Long): Flow> + fun getDetailsByPreviewId(previewId: Long): Flow> @Query("SELECT * FROM $DETAILS_TABLE WHERE detailsId=:detailId") - suspend fun getDetailById(detailId: Long): Details? + suspend fun getDetailById(detailId: Long): DetailsEntity? @Query("SELECT * FROM $DETAILS_TABLE WHERE previewId=:previewId AND question=:question AND answer=:answer") - suspend fun getDetailByContent(previewId: Long, question: String, answer: String): Details? + suspend fun getDetailByContent(previewId: Long, question: String, answer: String): DetailsEntity? @Upsert - suspend fun upsertDetails(details: Details): Long + suspend fun upsertDetails(details: DetailsEntity): Long @Delete - suspend fun deleteDetail(details: Details) + suspend fun deleteDetail(details: DetailsEntity) @Query("DELETE FROM $DETAILS_TABLE WHERE previewId=:previewId") suspend fun deleteDetailByPreviewId(previewId: Long) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt index 341364c..ed5b84c 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/PreviewDao.kt @@ -4,26 +4,36 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.bhardwaj.passkey.data.local.entity.Preview +import com.bhardwaj.passkey.data.local.entity.PreviewEntity import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_TABLE import kotlinx.coroutines.flow.Flow @Dao interface PreviewDao { @Query("SELECT * FROM $PREVIEW_TABLE ORDER BY sequence ASC") - fun getPreviews(): Flow> + fun getPreviews(): Flow> @Query("SELECT * FROM $PREVIEW_TABLE WHERE previewId=:previewId") - suspend fun getPreviewById(previewId: Long): Preview? + suspend fun getPreviewById(previewId: Long): PreviewEntity? - @Query("SELECT * FROM $PREVIEW_TABLE WHERE heading=:previewHeading AND categoryName=:categoryName") - suspend fun getPreviewByHeading(previewHeading: String, categoryName: String): Preview? + /** + * COLLATE NOCASE: duplicate detection was case-sensitive, so "Gmail" and "gmail" coexisted + * as separate entries and re-importing a differently-cased backup created duplicates. + */ + @Query( + "SELECT * FROM $PREVIEW_TABLE " + + "WHERE heading=:previewHeading COLLATE NOCASE AND categoryName=:categoryName" + ) + suspend fun getPreviewByHeading(previewHeading: String, categoryName: String): PreviewEntity? + + @Query("SELECT * FROM $PREVIEW_TABLE WHERE categoryName=:categoryName ORDER BY sequence ASC") + fun getPreviewsByCategory(categoryName: String): Flow> @Upsert - suspend fun upsertPreview(previews: Preview): Long + suspend fun upsertPreview(previews: PreviewEntity): Long @Delete - suspend fun deletePreview(previews: Preview) + suspend fun deletePreview(previews: PreviewEntity) @Query("UPDATE $PREVIEW_TABLE SET sequence=:sequence WHERE previewId=:previewId") suspend fun updatePreviewSequence(previewId: Long, sequence: Long) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/Details.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt similarity index 96% rename from app/src/main/java/com/bhardwaj/passkey/data/local/entity/Details.kt rename to app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt index ccf2df5..bbd7466 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/Details.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt @@ -7,7 +7,7 @@ import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE @Keep @Entity(tableName = DETAILS_TABLE) -data class Details( +data class DetailsEntity( @PrimaryKey(autoGenerate = true) val detailsId: Long? = null, val previewId: Long, diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/Preview.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/PreviewEntity.kt similarity index 76% rename from app/src/main/java/com/bhardwaj/passkey/data/local/entity/Preview.kt rename to app/src/main/java/com/bhardwaj/passkey/data/local/entity/PreviewEntity.kt index a7ac742..4c44ff7 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/Preview.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/PreviewEntity.kt @@ -3,15 +3,15 @@ package com.bhardwaj.passkey.data.local.entity import androidx.annotation.Keep import androidx.room.Entity import androidx.room.PrimaryKey -import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.utils.Constants @Keep @Entity(tableName = Constants.PREVIEW_TABLE) -data class Preview( +data class PreviewEntity( @PrimaryKey(autoGenerate = true) val previewId: Long? = null, var heading: String, - val categoryName: Categories, + val categoryName: Category, val sequence: Long = 0 ) \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt b/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt new file mode 100644 index 0000000..33de441 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt @@ -0,0 +1,41 @@ +package com.bhardwaj.passkey.data.mapper + +import com.bhardwaj.passkey.data.local.entity.DetailsEntity +import com.bhardwaj.passkey.data.local.entity.PreviewEntity +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview + +internal fun PreviewEntity.toDomain(): Preview = Preview( + // Safe by construction: Room always populates the primary key on a read. A row without one + // would mean the database is corrupt, which is worth failing loudly on. + id = requireNotNull(previewId) { "preview read from the database without an id" }, + heading = heading, + category = categoryName, + sequence = sequence +) + +internal fun Preview.toEntity(): PreviewEntity = PreviewEntity( + previewId = id.takeIf { it != 0L }, + heading = heading, + categoryName = category, + sequence = sequence +) + +internal fun DetailsEntity.toDomain(): Detail = Detail( + id = requireNotNull(detailsId) { "detail read from the database without an id" }, + previewId = previewId, + question = question, + answer = answer, + sequence = sequence, + isSecret = isSecret +) + +internal fun Detail.toEntity(): DetailsEntity = DetailsEntity( + detailsId = id.takeIf { it != 0L }, + previewId = previewId, + question = question, + answer = answer, + sequence = sequence, + isSecret = isSecret +) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt b/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt index fa68ea1..264663b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt @@ -1,99 +1,121 @@ package com.bhardwaj.passkey.data.repository import androidx.room.withTransaction +import com.bhardwaj.passkey.data.local.PasskeyDatabase import com.bhardwaj.passkey.data.local.VaultDatabaseProvider +import com.bhardwaj.passkey.data.mapper.toDomain +import com.bhardwaj.passkey.data.mapper.toEntity +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.domain.repository.PasskeyRepository import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import com.bhardwaj.passkey.data.local.PasskeyDatabase -import com.bhardwaj.passkey.data.local.dao.DetailsDao -import com.bhardwaj.passkey.data.local.dao.PreviewDao -import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.data.local.entity.Preview -import com.bhardwaj.passkey.domain.repository.PasskeyRepository -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map @OptIn(ExperimentalCoroutinesApi::class) class PasskeyRepositoryImpl( private val vault: VaultDatabaseProvider ) : PasskeyRepository { - private suspend fun previewDao(): PreviewDao = vault.requireDb().previewDao - private suspend fun detailsDao(): DetailsDao = vault.requireDb().detailsDao - /** - * Room Flows are resolved through the vault's current database rather than captured once. + * Room Flows are resolved through the vault's *current* database rather than a DAO captured + * at construction. * * This is load-bearing for locking: flatMapLatest detaches every collector when the database - * closes and re-subscribes when it reopens. Holding a DAO captured at construction would - * leave collectors on a closed database and throw - * "attempt to re-open an already-closed object" on the next emission. + * closes and re-subscribes when it reopens. A captured DAO would leave collectors on a closed + * database and throw "attempt to re-open an already-closed object" on the next emission. */ - private fun vaultFlow(block: (PasskeyDatabase) -> Flow>): Flow> = - vault.database.flatMapLatest { db -> db?.let(block) ?: flowOf(emptyList()) } + private fun vaultFlow( + query: (PasskeyDatabase) -> Flow>, + mapper: (E) -> T + ): Flow> = vault.database.flatMapLatest { db -> + db?.let { database -> query(database).map { rows -> rows.map(mapper) } } ?: flowOf(emptyList()) + } - override suspend fun runInTransaction(block: suspend () -> R): R = - vault.requireDb().withTransaction { block() } + override fun getPreviews(): Flow> = + vaultFlow({ it.previewDao.getPreviews() }) { it.toDomain() } - override suspend fun deleteAll() { - detailsDao().deleteAllDetails() - previewDao().deleteAllPreviews() - } - override fun getDetails(): Flow> = vaultFlow { it.detailsDao.getDetails() } + override fun getPreviewsByCategory(category: Category): Flow> = + vaultFlow({ it.previewDao.getPreviewsByCategory(category.name) }) { it.toDomain() } - override fun getDetailsByPreviewId(previewId: Long): Flow> = - vaultFlow { it.detailsDao.getDetailsByPreviewId(previewId) } + override suspend fun getPreviewById(previewId: Long): Preview? = + vault.requireDb().previewDao.getPreviewById(previewId)?.toDomain() - override suspend fun getDetailById(detailId: Long): Details? { - return detailsDao().getDetailById(detailId) - } + override suspend fun getPreviewByHeading(heading: String, category: Category): Preview? = + vault.requireDb().previewDao.getPreviewByHeading(heading, category.name)?.toDomain() - override suspend fun getDetailByContent( - previewId: Long, - question: String, - answer: String - ): Details? { - return detailsDao().getDetailByContent(previewId, question, answer) - } + override suspend fun createPreview(heading: String, category: Category, sequence: Long): Long = + vault.requireDb().previewDao.upsertPreview( + Preview(id = 0, heading = heading, category = category, sequence = sequence).toEntity() + ) - override suspend fun upsertDetails(details: Details): Long { - return detailsDao().upsertDetails(details) + override suspend fun updatePreview(preview: Preview) { + vault.requireDb().previewDao.upsertPreview(preview.toEntity()) } - override suspend fun deleteDetail(details: Details) { - return detailsDao().deleteDetail(details) + override suspend fun deletePreview(preview: Preview) { + vault.requireDb().previewDao.deletePreview(preview.toEntity()) } - override suspend fun deleteDetailByPreviewId(previewId: Long) { - return detailsDao().deleteDetailByPreviewId(previewId) + override suspend fun updatePreviewSequence(previewId: Long, sequence: Long) { + vault.requireDb().previewDao.updatePreviewSequence(previewId, sequence) } - override suspend fun updateDetailSequence(detailId: Long, sequence: Long) { - return detailsDao().updateDetailSequence(detailId, sequence) - } + override fun getDetails(): Flow> = + vaultFlow({ it.detailsDao.getDetails() }) { it.toDomain() } + + override fun getDetailsByPreviewId(previewId: Long): Flow> = + vaultFlow({ it.detailsDao.getDetailsByPreviewId(previewId) }) { it.toDomain() } - override fun getPreviews(): Flow> = vaultFlow { it.previewDao.getPreviews() } + override suspend fun getDetailByContent( + previewId: Long, + question: String, + answer: String + ): Detail? = vault.requireDb().detailsDao + .getDetailByContent(previewId, question, answer)?.toDomain() - override suspend fun getPreviewById(previewId: Long): Preview? { - return previewDao().getPreviewById(previewId) + override suspend fun createDetail( + previewId: Long, + question: String, + answer: String, + sequence: Long, + isSecret: Boolean + ): Long = vault.requireDb().detailsDao.upsertDetails( + Detail( + id = 0, + previewId = previewId, + question = question, + answer = answer, + sequence = sequence, + isSecret = isSecret + ).toEntity() + ) + + override suspend fun updateDetail(detail: Detail) { + vault.requireDb().detailsDao.upsertDetails(detail.toEntity()) } - override suspend fun getPreviewByHeading( - previewHeading: String, - categoryName: String - ): Preview? { - return previewDao().getPreviewByHeading(previewHeading, categoryName) + override suspend fun deleteDetail(detail: Detail) { + vault.requireDb().detailsDao.deleteDetail(detail.toEntity()) } - override suspend fun upsertPreview(previews: Preview): Long { - return previewDao().upsertPreview(previews) + override suspend fun deleteDetailsByPreviewId(previewId: Long) { + vault.requireDb().detailsDao.deleteDetailByPreviewId(previewId) } - override suspend fun deletePreview(previews: Preview) { - return previewDao().deletePreview(previews) + override suspend fun updateDetailSequence(detailId: Long, sequence: Long) { + vault.requireDb().detailsDao.updateDetailSequence(detailId, sequence) } - override suspend fun updatePreviewSequence(previewId: Long, sequence: Long) { - return previewDao().updatePreviewSequence(previewId, sequence) + override suspend fun runInTransaction(block: suspend () -> R): R = + vault.requireDb().withTransaction { block() } + + override suspend fun deleteAll() { + val db = vault.requireDb() + db.detailsDao.deleteAllDetails() + db.previewDao.deleteAllPreviews() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/Vault.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/Vault.kt new file mode 100644 index 0000000..b1958e1 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/Vault.kt @@ -0,0 +1,49 @@ +package com.bhardwaj.passkey.domain.model + +/** + * The entry categories. The enum *name* is the persisted value (Room column and backup files), + * so it must not change. A display label is a presentation concern and lives in the UI. + */ +enum class Category { + BANKS, + MAILS, + APPS, + OTHERS; + + companion object { + /** + * Never [valueOf]: it throws on unknown input, and an imported file is untrusted. + */ + fun fromNameOrOther(name: String?): Category = + entries.firstOrNull { it.name.equals(name?.trim(), ignoreCase = true) } ?: OTHERS + } +} + +/** + * A group of related entries. + * + * Deliberately not annotated @Immutable: Kotlin 2.x strong skipping already treats a data class + * of primitives and enums as stable, so the annotation would only couple the domain layer to the + * Compose runtime for nothing. + * + * [id] is non-null here even though the Room column is nullable. The entity is deliberately left + * alone - changing its nullability would alter Room's identity hash and require a migration for + * no functional gain - and the mapper asserts instead, which is always safe because Room + * populates the primary key on every read. That is what removes `previewId!!` from every + * call site above the data layer. + */ +data class Preview( + val id: Long, + val heading: String, + val category: Category, + val sequence: Long = 0 +) + +data class Detail( + val id: Long, + val previewId: Long, + val question: String, + val answer: String, + val sequence: Long = 0, + val isSecret: Boolean = false +) diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt index bf32daf..dbae4f1 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt @@ -1,39 +1,62 @@ package com.bhardwaj.passkey.domain.repository -import com.bhardwaj.passkey.data.local.entity.Details -import com.bhardwaj.passkey.data.local.entity.Preview +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview import kotlinx.coroutines.flow.Flow +/** + * The vault, in domain terms. Room entities no longer cross this boundary. + */ interface PasskeyRepository { - // Details - fun getDetails(): Flow> - fun getDetailsByPreviewId(previewId: Long): Flow> + // Previews - suspend fun getDetailById(detailId: Long): Details? + fun getPreviews(): Flow> - suspend fun getDetailByContent(previewId: Long, question: String, answer: String): Details? + /** + * Filtering in SQL rather than in the ViewModel. Reordering used to renumber a list that had + * already been filtered by category *and* search text, while the query ordered globally, so + * sequences collided across categories and the order was quietly unstable. + */ + fun getPreviewsByCategory(category: Category): Flow> - suspend fun upsertDetails(details: Details): Long + suspend fun getPreviewById(previewId: Long): Preview? - suspend fun deleteDetail(details: Details) + suspend fun getPreviewByHeading(heading: String, category: Category): Preview? - suspend fun deleteDetailByPreviewId(previewId: Long) + /** Returns the new row id. */ + suspend fun createPreview(heading: String, category: Category, sequence: Long = 0): Long - suspend fun updateDetailSequence(detailId: Long, sequence: Long) + suspend fun updatePreview(preview: Preview) - // Preview - fun getPreviews(): Flow> + suspend fun deletePreview(preview: Preview) - suspend fun getPreviewById(previewId: Long): Preview? + suspend fun updatePreviewSequence(previewId: Long, sequence: Long) - suspend fun getPreviewByHeading(previewHeading: String, categoryName: String): Preview? + // Details - suspend fun upsertPreview(previews: Preview): Long + fun getDetails(): Flow> - suspend fun deletePreview(previews: Preview) + fun getDetailsByPreviewId(previewId: Long): Flow> - suspend fun updatePreviewSequence(previewId: Long, sequence: Long) + suspend fun getDetailByContent(previewId: Long, question: String, answer: String): Detail? + + suspend fun createDetail( + previewId: Long, + question: String, + answer: String, + sequence: Long = 0, + isSecret: Boolean = false + ): Long + + suspend fun updateDetail(detail: Detail) + + suspend fun deleteDetail(detail: Detail) + + suspend fun deleteDetailsByPreviewId(previewId: Long) + + suspend fun updateDetailSequence(detailId: Long, sequence: Long) // Cross-cutting @@ -41,11 +64,10 @@ interface PasskeyRepository { * Runs [block] inside a single database transaction. * * Import previously wrote row by row with no transaction, so a malformed row part-way - * through a file left the vault half-populated with no way back. Reordering had the same - * problem: N separate sequence writes that could be interrupted between any two. + * through a file left the vault half-populated with no way back. */ suspend fun runInTransaction(block: suspend () -> R): R /** Removes every entry. Only meaningful inside [runInTransaction]. */ suspend fun deleteAll() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt index 9ef4a07..d5f9a14 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt @@ -12,7 +12,7 @@ import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewScreen import com.bhardwaj.passkey.presentation.screens.security_screen.VaultGateScreen import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsScreen import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashPage -import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.domain.model.Category @Composable fun NavGraph( @@ -40,7 +40,7 @@ fun NavGraph( route = NavScreens.PreviewPage.route + "?categoryName={categoryName}", arguments = listOf(navArgument(name = "categoryName") { type = NavType.StringType - defaultValue = Categories.BANKS.name + defaultValue = Category.BANKS.name }), ) { PreviewScreen(onNavigate = { navController.navigate(it.route) }) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/CategoryLabel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/CategoryLabel.kt new file mode 100644 index 0000000..00e1ae5 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/CategoryLabel.kt @@ -0,0 +1,21 @@ +package com.bhardwaj.passkey.presentation.screens.common + +import androidx.annotation.StringRes +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.domain.model.Category + +/** + * Display label for a [Category]. + * + * Lives in the presentation layer rather than on the enum: the enum's *name* is persisted in the + * database and in backup files, so it must stay stable, while its label is localized and is + * purely a UI concern. Keeping the mapping here also stops the domain layer depending on + * generated R. + */ +@StringRes +fun Category.labelRes(): Int = when (this) { + Category.BANKS -> R.string.banks + Category.MAILS -> R.string.mails + Category.APPS -> R.string.apps + Category.OTHERS -> R.string.others +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt index 502a654..3ac2b1a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt @@ -1,7 +1,7 @@ package com.bhardwaj.passkey.presentation.screens.detail_screen import androidx.compose.foundation.lazy.LazyListItemInfo -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail sealed interface DetailEvents { // Dialogs. @@ -16,8 +16,8 @@ sealed interface DetailEvents { // Single Item. data class OnLongPress(val detailsDescription: String) : DetailEvents data class OnReorderDetails(val from: LazyListItemInfo, val to: LazyListItemInfo) : DetailEvents - data class OnChangeClick(val details: Details) : DetailEvents - data class OnSwipedLeft(val details: Details) : DetailEvents + data class OnChangeClick(val details: Detail) : DetailEvents + data class OnSwipedLeft(val details: Detail) : DetailEvents // Floating Action Button data object OnAddDetailClick : DetailEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 896315c..e2f2c70 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -194,7 +194,7 @@ fun DetailScreen( ) { items( items = details, - key = { item -> "${item.detailsId}" } + key = { item -> item.id } ) { detail -> if (searchText.isNotBlank()) { DetailsItem(details = detail, onEvent = viewModel::onEvent) @@ -212,7 +212,7 @@ fun DetailScreen( } ReorderableItem( reorderableLazyColumnState, - "${detail.detailsId}" + "${detail.id}" ) { SwipeToDismissBox( state = state, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt index cc7ea03..b75009e 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt @@ -8,7 +8,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING @@ -55,7 +55,7 @@ class DetailViewModel @Inject constructor( private val _searchText = MutableStateFlow("") val searchText = _searchText.asStateFlow() - val details: StateFlow> = repository.getDetailsByPreviewId(previewId = previewId) + val details: StateFlow> = repository.getDetailsByPreviewId(previewId = previewId) .combine(searchText) { details, text -> if (text.isBlank()) { details @@ -76,7 +76,7 @@ class DetailViewModel @Inject constructor( var isAlertOpen by mutableStateOf(false) private set - var detail by mutableStateOf(null) + var detail by mutableStateOf(null) private set var isPasswordSettingsOpen by mutableStateOf(false) @@ -93,7 +93,7 @@ class DetailViewModel @Inject constructor( var includeSpecial by mutableStateOf(false) private set - private var deletedDetail: Details? = null + private var deletedDetail: Detail? = null fun onEvent(event: DetailEvents) { when (event) { @@ -112,13 +112,13 @@ class DetailViewModel @Inject constructor( is DetailEvents.OnChangeClick -> { viewModelScope.launch { _searchText.value = "" - repository.getDetailById(event.details.detailsId!!)?.let { detail -> - isSheetOpen = true - savedStateHandle[BOTTOM_SHEET_HEADING] = true - _detailTitle.value = event.details.question - _detailResponse.value = event.details.answer - this@DetailViewModel.detail = detail - } + // The row is already a fully-formed domain object; re-reading it by id was + // a needless round trip. + isSheetOpen = true + savedStateHandle[BOTTOM_SHEET_HEADING] = true + _detailTitle.value = event.details.question + _detailResponse.value = event.details.answer + detail = event.details } } @@ -152,27 +152,27 @@ class DetailViewModel @Inject constructor( ) return@launch } - val newDetail = Details( - previewId = previewId, - question = detailTitle.value.trim(), - answer = detailResponse.value.trim(), - // A value that came out of the generator is unambiguously a secret, so - // the analyser never has to guess for it. - isSecret = wasGenerated - ) + val question = detailTitle.value.trim() + val answer = detailResponse.value.trim() detail?.let { - repository.upsertDetails( + repository.updateDetail( it.copy( - previewId = previewId, - // Trimmed to match the create path above. A trailing space in a - // stored password fails silently wherever it is pasted. - question = newDetail.question, - answer = newDetail.answer, - isSecret = it.isSecret || wasGenerated, + // Trimmed on edit too. A trailing space in a stored password + // fails silently wherever it is pasted. + question = question, + answer = answer, + isSecret = it.isSecret || wasGenerated ) ) - } ?: repository.upsertDetails(newDetail) + } ?: repository.createDetail( + previewId = previewId, + question = question, + answer = answer, + // A value that came out of the generator is unambiguously a secret, so + // the analyser never has to guess for it. + isSecret = wasGenerated + ) detail = null _detailTitle.value = "" @@ -196,7 +196,7 @@ class DetailViewModel @Inject constructor( DetailEvents.OnDismissAlertDialog -> { viewModelScope.launch { isAlertOpen = false - deletedDetail?.let { repository.upsertDetails(it) } + deletedDetail?.let { repository.updateDetail(it) } deletedDetail = null } } @@ -204,7 +204,7 @@ class DetailViewModel @Inject constructor( DetailEvents.OnCancelClick -> { viewModelScope.launch { isAlertOpen = false - deletedDetail?.let { repository.upsertDetails(it) } + deletedDetail?.let { repository.updateDetail(it) } deletedDetail = null } } @@ -231,7 +231,7 @@ class DetailViewModel @Inject constructor( repository.runInTransaction { updatedList.forEach { detail -> repository.updateDetailSequence( - detailId = detail.detailsId!!, + detailId = detail.id, sequence = detail.sequence ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt index 06d3f25..f57c380 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins @@ -43,7 +43,7 @@ import sh.calvin.reorderable.ReorderableCollectionItemScope fun DetailsItem( modifier: Modifier = Modifier, scope: ReorderableCollectionItemScope? = null, - details: Details, + details: Detail, onEvent: (DetailEvents) -> Unit, ) { val view = LocalView.current diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt index fe6e2eb..df6908f 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt @@ -1,7 +1,7 @@ package com.bhardwaj.passkey.presentation.screens.preview_screen import androidx.compose.foundation.lazy.LazyListItemInfo -import com.bhardwaj.passkey.data.local.entity.Preview +import com.bhardwaj.passkey.domain.model.Preview sealed interface PreviewEvents { // Dialogs. diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index 19fd204..10c3daf 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.labelRes import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewViewModel import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton @@ -68,7 +69,7 @@ import com.bhardwaj.passkey.presentation.screens.preview_screen.components.Previ import com.bhardwaj.passkey.presentation.screens.preview_screen.components.PreviewItem import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.utils.ButtonType -import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.utils.asString import com.bhardwaj.passkey.utils.SecureClipboard import com.bhardwaj.passkey.utils.UiText @@ -84,9 +85,9 @@ fun PreviewScreen( viewModel: PreviewViewModel = hiltViewModel() ) { val categoryName by viewModel.categoryName.collectAsState() - // Localized display names come from Categories.labelRes, which the bottom navigation + // Localized display names come from Category.labelRes, which the bottom navigation // also uses, so the two can no longer drift apart. - val categoryNameMap = Categories.entries.associate { it.name to stringResource(it.labelRes) } + val categoryNameMap = Category.entries.associate { it.name to stringResource(it.labelRes()) } val previewHeading by viewModel.previewHeading.collectAsState() val isEditingSheet by viewModel.isEditingSheet.collectAsState() @@ -244,7 +245,7 @@ fun PreviewScreen( ) { items( items = previews, - key = { item -> "${item.previewId}" } + key = { item -> item.id } ) { preview -> if (searchText.isNotBlank()) { PreviewItem( @@ -265,7 +266,7 @@ fun PreviewScreen( } ReorderableItem( reorderableLazyColumnState, - "${preview.previewId}" + "${preview.id}" ) { SwipeToDismissBox( state = state, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt index f72eefb..eee91fc 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt @@ -7,23 +7,25 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.data.local.entity.Preview +import com.bhardwaj.passkey.domain.model.Preview import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.navigation.Routes -import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_CATEGORY_NAME import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_HEADING import com.bhardwaj.passkey.utils.UiEvents import com.bhardwaj.passkey.utils.UiText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -37,7 +39,7 @@ class PreviewViewModel @Inject constructor( private val _uiEvents = Channel() val uiEvents = _uiEvents.receiveAsFlow() - val categoryName = savedStateHandle.getStateFlow(PREVIEW_CATEGORY_NAME, Categories.BANKS.name) + val categoryName = savedStateHandle.getStateFlow(PREVIEW_CATEGORY_NAME, Category.BANKS.name) val previewHeading = savedStateHandle.getStateFlow(PREVIEW_HEADING, "") /** true = editing an existing row, false = adding. Resolved to text by the UI. */ val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) @@ -45,17 +47,18 @@ class PreviewViewModel @Inject constructor( private val _searchText = MutableStateFlow("") val searchText = _searchText.asStateFlow() - val previews: StateFlow> = repository.getPreviews() - .combine(categoryName) { previews, categoryName -> - previews.filter { it.categoryName == Categories.valueOf(categoryName) } - }.combine(searchText) { previews, text -> - if (text.isBlank()) { - previews - } else { - previews.filter { it.heading.contains(text, ignoreCase = true) } - } + @OptIn(ExperimentalCoroutinesApi::class) + val previews: StateFlow> = categoryName + // Filtered in SQL now, so reordering renumbers within a single category instead of a + // list that had already been narrowed in memory. + .flatMapLatest { name -> repository.getPreviewsByCategory(Category.valueOf(name)) } + .combine(searchText) { previews, text -> + if (text.isBlank()) previews + else previews.filter { it.heading.contains(text, ignoreCase = true) } } - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // WhileSubscribed, not Lazily: Lazily keeps the Room invalidation observer alive for the + // ViewModel's entire life even while the screen is backgrounded. + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) var isSheetOpen by mutableStateOf(false) private set @@ -91,7 +94,7 @@ class PreviewViewModel @Inject constructor( is PreviewEvents.OnChangeClick -> { viewModelScope.launch { _searchText.value = "" - repository.getPreviewById(event.preview.previewId!!)?.let { preview -> + repository.getPreviewById(event.preview.id)?.let { preview -> isSheetOpen = true savedStateHandle[BOTTOM_SHEET_HEADING] = true savedStateHandle[PREVIEW_HEADING] = event.preview.heading @@ -116,20 +119,15 @@ class PreviewViewModel @Inject constructor( return@launch } - val newPreview = Preview( - heading = previewHeading.value.trim(), - categoryName = Categories.valueOf(categoryName.value) - ) + val heading = previewHeading.value.trim() + val category = Category.valueOf(categoryName.value) - val existingPreview = repository.getPreviewByHeading( - newPreview.heading, - newPreview.categoryName.toString() - ) + val existingPreview = repository.getPreviewByHeading(heading, category) // When editing, the lookup finds the very row being edited. Treating that as // a clash meant saving an edit without renaming it reported "heading exists" // and silently discarded the edit. val isClashWithAnotherRow = - existingPreview != null && existingPreview.previewId != preview?.previewId + existingPreview != null && existingPreview.id != preview?.id if (isClashWithAnotherRow) { preview = null savedStateHandle[PREVIEW_HEADING] = "" @@ -143,13 +141,8 @@ class PreviewViewModel @Inject constructor( } preview?.let { - repository.upsertPreview( - it.copy( - heading = newPreview.heading, - categoryName = newPreview.categoryName - ) - ) - } ?: repository.upsertPreview(newPreview) + repository.updatePreview(it.copy(heading = heading, category = category)) + } ?: repository.createPreview(heading = heading, category = category) preview = null savedStateHandle[PREVIEW_HEADING] = "" @@ -182,7 +175,7 @@ class PreviewViewModel @Inject constructor( PreviewEvents.OnDismissAlertDialog -> { viewModelScope.launch { isAlertOpen = false - deletedPreview?.let { repository.upsertPreview(it) } + deletedPreview?.let { repository.updatePreview(it) } deletedPreview = null } } @@ -190,14 +183,14 @@ class PreviewViewModel @Inject constructor( PreviewEvents.OnCancelClick -> { viewModelScope.launch { isAlertOpen = false - deletedPreview?.let { repository.upsertPreview(it) } + deletedPreview?.let { repository.updatePreview(it) } deletedPreview = null } } is PreviewEvents.OnDeleteClick -> { viewModelScope.launch { - deletedPreview?.let { repository.deleteDetailByPreviewId(it.previewId!!) } + deletedPreview?.let { repository.deleteDetailsByPreviewId(it.id) } isAlertOpen = false deletedPreview = null } @@ -222,7 +215,7 @@ class PreviewViewModel @Inject constructor( repository.runInTransaction { updatedList.forEach { preview -> repository.updatePreviewSequence( - previewId = preview.previewId!!, + previewId = preview.id, sequence = preview.sequence ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/MainBottomNavigation.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/MainBottomNavigation.kt index cc90236..a8702eb 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/MainBottomNavigation.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/MainBottomNavigation.kt @@ -27,10 +27,11 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp -import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.presentation.screens.common.labelRes data class BottomNavigationItem( - val category: Categories, + val category: Category, val icon: ImageVector, val isVisible: Boolean = true ) { @@ -40,26 +41,26 @@ data class BottomNavigationItem( val bottomNavigationList = listOf( BottomNavigationItem( - category = Categories.BANKS, + category = Category.BANKS, icon = Icons.Filled.AccountBalance ), BottomNavigationItem( - category = Categories.APPS, + category = Category.APPS, icon = Icons.Filled.Gamepad ), // Invisible spacer that reserves room for the centre FAB notch. It is not a destination. BottomNavigationItem( - category = Categories.BANKS, + category = Category.BANKS, icon = Icons.Filled.AccountBalance, isVisible = false, ), BottomNavigationItem( - category = Categories.MAILS, + category = Category.MAILS, icon = Icons.Filled.Email ), BottomNavigationItem( - category = Categories.OTHERS, + category = Category.OTHERS, icon = Icons.AutoMirrored.Filled.Article ) ) @@ -84,7 +85,7 @@ fun MainBottomNavigation( imageVector = item.icon, // Previously the raw enum name ("BANKS"), announced in English in every locale. contentDescription = if (item.isVisible) { - stringResource(id = item.category.labelRes) + stringResource(id = item.category.labelRes()) } else { null }, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt index 218bd33..9e01057 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt @@ -34,7 +34,7 @@ import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.theme.Poppins import sh.calvin.reorderable.ReorderableCollectionItemScope -import com.bhardwaj.passkey.data.local.entity.Preview as PreviewEntity +import com.bhardwaj.passkey.domain.model.Preview as PreviewEntity @Composable fun PreviewItem( @@ -87,7 +87,7 @@ fun PreviewItem( .weight(1F) .combinedClickable( onClick = { - onEvent(PreviewEvents.OnPreviewClick(preview.previewId!!)) + onEvent(PreviewEvents.OnPreviewClick(preview.id)) }, onLongClick = { onEvent(PreviewEvents.OnLongPress(preview.heading)) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt index e166701..1cea443 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt @@ -3,7 +3,7 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen import android.net.Uri import com.bhardwaj.passkey.data.backup.ImportMode import com.bhardwaj.passkey.domain.model.AutoLockTimeout -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.domain.model.Language sealed interface SettingsEvents { @@ -35,7 +35,7 @@ sealed interface SettingsEvents { } } - data class OnAnalysisItemClick(val detail: Details) : SettingsEvents + data class OnAnalysisItemClick(val detail: Detail) : SettingsEvents data object OnLanguageClick : SettingsEvents data object OnDismissBottomSheet : SettingsEvents data object OnRateAppClick : SettingsEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index d264a30..631fd43 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -26,7 +26,7 @@ import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.utils.AlertBy.ABOUT import com.bhardwaj.passkey.utils.AlertBy.PRIVACY import com.bhardwaj.passkey.utils.AlertBy.TERMS_N_CONDITIONS -import com.bhardwaj.passkey.utils.Categories +import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.utils.PasswordAnalysisResult import com.bhardwaj.passkey.utils.PasswordAnalyzer import com.bhardwaj.passkey.utils.UiEvents diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt index ead603a..69a6c0d 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt @@ -51,7 +51,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins import com.bhardwaj.passkey.utils.PasswordAnalysisResult @@ -61,7 +61,7 @@ import com.bhardwaj.passkey.utils.PasswordAnalysisResult fun AnalysisBottomSheet( result: PasswordAnalysisResult, onDismiss: () -> Unit, - onDetailClick: (Details) -> Unit + onDetailClick: (Detail) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -249,7 +249,7 @@ fun AnalysisSectionHeader(title: String, icon: ImageVector, color: Color) { } @Composable -fun SimpleAnalysisItem(detail: Details, onClick: () -> Unit) { +fun SimpleAnalysisItem(detail: Detail, onClick: () -> Unit) { Card( shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -299,8 +299,8 @@ fun SimpleAnalysisItem(detail: Details, onClick: () -> Unit) { fun ReusedAnalysisItem( password: String, count: Int, - examples: List
, - onDetailClick: (Details) -> Unit + examples: List, + onDetailClick: (Detail) -> Unit ) { Card( shape = RoundedCornerShape(8.dp), diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt b/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt deleted file mode 100644 index bae470a..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.bhardwaj.passkey.utils - -import androidx.annotation.StringRes -import com.bhardwaj.passkey.R - -/** - * The entry categories. The enum *name* is the persisted value (Room column and backup files), - * so it must not change; [labelRes] carries the localized display name instead. - */ -enum class Categories(@param:StringRes val labelRes: Int) { - BANKS(R.string.banks), - MAILS(R.string.mails), - APPS(R.string.apps), - OTHERS(R.string.others) -} diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/Migration_2_3.kt b/app/src/main/java/com/bhardwaj/passkey/utils/Migration_2_3.kt index 85f7bbf..df2bbf5 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/Migration_2_3.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/Migration_2_3.kt @@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE /** - * Adds Details.isSecret. + * Adds Detail.isSecret. * * Purely additive with a default, so no table rebuild and nothing to roll back. Existing rows * stay 0 and keep being classified by keyword matching; only newly created or edited rows carry diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/PasswordAnalyser.kt b/app/src/main/java/com/bhardwaj/passkey/utils/PasswordAnalyser.kt index c806a95..f1d21b2 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/PasswordAnalyser.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/PasswordAnalyser.kt @@ -1,13 +1,13 @@ package com.bhardwaj.passkey.utils -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail import java.text.Normalizer import java.util.Locale data class PasswordAnalysisResult( val totalPasswords: Int = 0, - val weakPasswords: List
= emptyList(), - val reusedPasswords: Map> = emptyMap(), + val weakPasswords: List = emptyList(), + val reusedPasswords: Map> = emptyMap(), val strengthScore: Int = 0 ) @@ -22,7 +22,7 @@ object PasswordAnalyzer { * locales nothing matched: totalPasswords came back 0, the score came back 100, and users * were told their vault was perfect. That is worse than having no feature at all. */ - fun analyze(details: List
, secretKeywords: Set): PasswordAnalysisResult { + fun analyze(details: List, secretKeywords: Set): PasswordAnalysisResult { val normalizedKeywords = secretKeywords.map { it.normalizeForMatch() }.filter { it.isNotBlank() } val passwordEntries = details.filter { detail -> diff --git a/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt b/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt index bdc68a8..5b8bb42 100644 --- a/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt +++ b/app/src/test/java/com/bhardwaj/passkey/PasswordAnalyzerTest.kt @@ -1,6 +1,6 @@ package com.bhardwaj.passkey -import com.bhardwaj.passkey.data.local.entity.Details +import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.utils.PasswordAnalyzer import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -12,8 +12,8 @@ class PasswordAnalyzerTest { private fun detail( id: Long, question: String, answer: String, isSecret: Boolean = false - ) = Details( - detailsId = id, previewId = 1, question = question, answer = answer, isSecret = isSecret + ) = Detail( + id = id, previewId = 1, question = question, answer = answer, isSecret = isSecret ) @Test @@ -63,7 +63,7 @@ class PasswordAnalyzerTest { detail(5, "Password 5", "Str0ng!Pass") // fine ) val result = PasswordAnalyzer.analyze(details, english) - assertThat(result.weakPasswords.map { it.detailsId }).containsExactly(1L, 2L, 3L, 4L) + assertThat(result.weakPasswords.map { it.id }).containsExactly(1L, 2L, 3L, 4L) } @Test From 83634be916dee5400605cb34883c9d9299cd81ce Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 11:17:05 +0530 Subject: [PATCH 14/25] Collect effects and state lifecycle-aware Every screen collected one-off effects with `LaunchedEffect(key1 = true) { viewModel.uiEvents.collect { ... } }`, which is not lifecycle-aware. It kept collecting while the app was backgrounded, so a snackbar could be consumed and lost with no window to show it in. ObserveAsEvents wraps repeatOnLifecycle(STARTED) instead. The Dispatchers.Main.immediate hop inside it matters: without it, an effect emitted just as the lifecycle drops below STARTED can be dispatched into a composition that is already gone. The producing side stays a Channel rather than moving to SharedFlow, on purpose. When repeatOnLifecycle cancels the collector on STOP, undelivered items remain buffered in the channel and are redelivered on restart; a SharedFlow would drop them. This is the one part of the original architecture that was already right. The channels do change capacity though. Channel() defaults to RENDEZVOUS, which suspends the sender until a collector receives - harmless when collection never stopped, but now a backgrounded screen has no active collector and the coroutine that emitted the effect would block until the user returned. They are BUFFERED. State collection moves from collectAsState to collectAsStateWithLifecycle for the same reason: nine call sites were recomposing while backgrounded. The one remaining LaunchedEffect(key1 = true) is the splash screen's logo animation, which genuinely should run once and is not an effect collector. Co-Authored-By: Claude Opus 5 (1M context) --- .../screens/common/ObserveAsEvents.kt | 34 +++++++++++++++++++ .../screens/detail_screen/DetailPage.kt | 17 +++++----- .../screens/detail_screen/DetailViewModel.kt | 5 ++- .../onboarding_screens/OnBoardingPage.kt | 5 ++- .../onboarding_screens/OnBoardingViewModel.kt | 5 ++- .../screens/preview_screen/PreviewPage.kt | 17 +++++----- .../preview_screen/PreviewViewModel.kt | 5 ++- .../screens/settings_screen/SettingsPage.kt | 27 +++++++-------- .../settings_screen/SettingsViewModel.kt | 5 ++- .../screens/splash_screen/SplashPage.kt | 5 ++- .../screens/splash_screen/SplashViewModel.kt | 5 ++- 11 files changed, 87 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/ObserveAsEvents.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/ObserveAsEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/ObserveAsEvents.kt new file mode 100644 index 0000000..30e1f38 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/ObserveAsEvents.kt @@ -0,0 +1,34 @@ +package com.bhardwaj.passkey.presentation.screens.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +/** + * Collects one-off effects only while the screen is at least STARTED. + * + * Replaces `LaunchedEffect(key1 = true) { flow.collect { ... } }`, which is not lifecycle-aware: + * it kept collecting while the app was backgrounded, so a snackbar could be consumed and lost + * with no window to show it in. + * + * The `Dispatchers.Main.immediate` hop matters. Without it an effect emitted just as the + * lifecycle drops below STARTED can be dispatched into a composition that is already gone. + * + * The producing side stays a Channel rather than a SharedFlow on purpose: when repeatOnLifecycle + * cancels this collector on STOP, undelivered items remain buffered in the channel and are + * redelivered on restart. A SharedFlow would drop them. + */ +@Composable +fun ObserveAsEvents(flow: Flow, key: Any? = null, onEvent: (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner, key) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { flow.collect(onEvent) } + } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index e2f2c70..d018e3a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.detail_screen +import androidx.lifecycle.compose.collectAsStateWithLifecycle import android.os.Build import android.view.HapticFeedbackConstants import androidx.compose.foundation.ExperimentalFoundationApi @@ -31,7 +32,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -47,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailViewModel @@ -71,14 +72,14 @@ fun DetailScreen( onPopBackStack: () -> Unit, viewModel: DetailViewModel = hiltViewModel() ) { - val detailTitle by viewModel.detailTitle.collectAsState() - val detailResponse by viewModel.detailResponse.collectAsState() - val isEditingSheet by viewModel.isEditingSheet.collectAsState() + val detailTitle by viewModel.detailTitle.collectAsStateWithLifecycle() + val detailResponse by viewModel.detailResponse.collectAsStateWithLifecycle() + val isEditingSheet by viewModel.isEditingSheet.collectAsStateWithLifecycle() // Resolved here rather than in the ViewModel so it follows the app locale. val bottomSheetHeading = stringResource(if (isEditingSheet) R.string.edit else R.string.add) - val searchText by viewModel.searchText.collectAsState() - val details by viewModel.details.collectAsState(initial = emptyList()) + val searchText by viewModel.searchText.collectAsStateWithLifecycle() + val details by viewModel.details.collectAsStateWithLifecycle() val snackBarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -94,8 +95,7 @@ fun DetailScreen( val context = LocalContext.current - LaunchedEffect(key1 = true) { - viewModel.uiEvents.collect { event -> + ObserveAsEvents(viewModel.uiEvents) { event -> when (event) { is UiEvents.PopBackStack -> onPopBackStack() is UiEvents.ShowSnackBar -> { @@ -125,7 +125,6 @@ fun DetailScreen( else -> Unit } - } } Scaffold( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt index b75009e..38f2f77 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt @@ -32,7 +32,10 @@ class DetailViewModel @Inject constructor( private val repository: PasskeyRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _uiEvents = Channel() + // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded + // screen has no active collector, and a rendezvous channel would suspend the coroutine + // that emitted the effect until the user came back. + private val _uiEvents = Channel(Channel.BUFFERED) val uiEvents = _uiEvents.receiveAsFlow() // Deliberately NOT in SavedStateHandle. That is serialized into the saved-instance-state diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt index ae281fe..c20be22 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingEvents import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingViewModel @@ -51,13 +52,11 @@ fun OnBoardingScreen( ) ) - LaunchedEffect(key1 = true) { - viewModel.uiEvents.collect { event -> + ObserveAsEvents(viewModel.uiEvents) { event -> when (event) { is UiEvents.Navigate -> onNavigate(event) else -> Unit } - } } Column( modifier = Modifier diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt index 7cf98c3..e7d7b39 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt @@ -17,7 +17,10 @@ class OnBoardingViewModel @Inject constructor( private val preferences: PreferencesRepository ) : ViewModel() { - private val _uiEvents = Channel() + // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded + // screen has no active collector, and a rendezvous channel would suspend the coroutine + // that emitted the effect until the user came back. + private val _uiEvents = Channel(Channel.BUFFERED) val uiEvents = _uiEvents.receiveAsFlow() fun onEvent(event: OnBoardingEvents) { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index 10c3daf..5d320c0 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.preview_screen +import androidx.lifecycle.compose.collectAsStateWithLifecycle import android.os.Build import android.view.HapticFeedbackConstants import androidx.compose.foundation.ExperimentalFoundationApi @@ -39,7 +40,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -58,6 +58,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.common.labelRes import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents @@ -84,18 +85,18 @@ fun PreviewScreen( onNavigate: (UiEvents.Navigate) -> Unit, viewModel: PreviewViewModel = hiltViewModel() ) { - val categoryName by viewModel.categoryName.collectAsState() + val categoryName by viewModel.categoryName.collectAsStateWithLifecycle() // Localized display names come from Category.labelRes, which the bottom navigation // also uses, so the two can no longer drift apart. val categoryNameMap = Category.entries.associate { it.name to stringResource(it.labelRes()) } - val previewHeading by viewModel.previewHeading.collectAsState() - val isEditingSheet by viewModel.isEditingSheet.collectAsState() + val previewHeading by viewModel.previewHeading.collectAsStateWithLifecycle() + val isEditingSheet by viewModel.isEditingSheet.collectAsStateWithLifecycle() // Resolved here rather than in the ViewModel so it follows the app locale. val bottomSheetHeading = stringResource(if (isEditingSheet) R.string.edit else R.string.add) - val searchText by viewModel.searchText.collectAsState() - val previews by viewModel.previews.collectAsState() + val searchText by viewModel.searchText.collectAsStateWithLifecycle() + val previews by viewModel.previews.collectAsStateWithLifecycle() var selectedIndex by rememberSaveable { mutableIntStateOf(0) } @@ -112,8 +113,7 @@ fun PreviewScreen( val context = LocalContext.current - LaunchedEffect(key1 = true) { - viewModel.uiEvents.collect { event -> + ObserveAsEvents(viewModel.uiEvents) { event -> when (event) { is UiEvents.Navigate -> onNavigate(event) is UiEvents.ShowSnackBar -> { @@ -143,7 +143,6 @@ fun PreviewScreen( else -> Unit } - } } Scaffold( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt index eee91fc..4df9fc8 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt @@ -36,7 +36,10 @@ class PreviewViewModel @Inject constructor( private val repository: PasskeyRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _uiEvents = Channel() + // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded + // screen has no active collector, and a rendezvous channel would suspend the coroutine + // that emitted the effect until the user came back. + private val _uiEvents = Channel(Channel.BUFFERED) val uiEvents = _uiEvents.receiveAsFlow() val categoryName = savedStateHandle.getStateFlow(PREVIEW_CATEGORY_NAME, Category.BANKS.name) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index f36ee03..1d0aebf 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -53,6 +53,7 @@ import java.util.Date import java.util.Locale import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bhardwaj.passkey.presentation.screens.settings_screen.components.AutoLockDialog +import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEvents import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsViewModel @@ -163,22 +164,20 @@ fun SettingsScreen( val isAnalysisSheetOpen = viewModel.isAnalysisSheetOpen val analysisResult = viewModel.analysisResult - LaunchedEffect(key1 = true) { - viewModel.uiEvents.collect { event -> - when (event) { - is UiEvents.PopBackStack -> onPopBackStack() - is UiEvents.Navigate -> onNavigate(event.route) - is UiEvents.ShowSnackBar -> { - scope.launch { - snackBarHostState.showSnackbar( - message = event.message.asString(context), - actionLabel = event.action?.asString(context) - ) - } + ObserveAsEvents(viewModel.uiEvents) { event -> + when (event) { + is UiEvents.PopBackStack -> onPopBackStack() + is UiEvents.Navigate -> onNavigate(event.route) + is UiEvents.ShowSnackBar -> { + scope.launch { + snackBarHostState.showSnackbar( + message = event.message.asString(context), + actionLabel = event.action?.asString(context) + ) } - - else -> Unit } + + else -> Unit } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index 631fd43..72cbdcc 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -54,7 +54,10 @@ class SettingsViewModel @Inject constructor( private val keyManager: DatabaseKeyManager, private val appContext: Application ) : ViewModel() { - private val _uiEvents = Channel() + // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded + // screen has no active collector, and a rendezvous channel would suspend the coroutine + // that emitted the effect until the user came back. + private val _uiEvents = Channel(Channel.BUFFERED) val uiEvents = _uiEvents.receiveAsFlow() var appVersion: String by mutableStateOf( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt index 6279938..8964813 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashEvents import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashViewModel @@ -44,13 +45,11 @@ fun SplashPage( Animatable(0.3F) } - LaunchedEffect(key1 = true) { - viewModel.uiEvents.collect { event -> + ObserveAsEvents(viewModel.uiEvents) { event -> when (event) { is UiEvents.Navigate -> onNavigate(event) else -> Unit } - } } LaunchedEffect(key1 = true) { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt index 4b44948..8b55271 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt @@ -24,7 +24,10 @@ class SplashViewModel @Inject constructor( var startDestination by mutableStateOf(NavScreens.SplashPage.route) private set - private val _uiEvents = Channel() + // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded + // screen has no active collector, and a rendezvous channel would suspend the coroutine + // that emitted the effect until the user came back. + private val _uiEvents = Channel(Channel.BUFFERED) val uiEvents = _uiEvents.receiveAsFlow() fun onEvent(event: SplashEvents) { From 1488bf6c8cf1405fb1e14244933c53493d32093b Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 11:29:36 +0530 Subject: [PATCH 15/25] Replace string routes with type-safe navigation Navigation was stringly typed. `Routes.DETAILS_PAGE + "?previewId=${...}"` appeared in two ViewModels as independently maintained copies of one URL format, nothing checked that the argument name matched the graph, and DetailViewModel read its argument back with `savedStateHandle.get("previewId") ?: -1`. That sentinel then needed a "something went wrong" branch for an id that could never legitimately arrive. Routes are now @Serializable objects. UiEvents.Navigate carries a NavRoute instead of a String, the graph declares `composable`, and the ViewModel reads `savedStateHandle.toRoute().previewId` as a non-null Long. The unreachable sentinel branch is gone with it. SplashViewModel's `startDestination` is deleted. It was a mutable ViewModel field fed into NavHost's startDestination, and changing that after first composition recreates the graph and resets the back stack. Splash is now a constant start destination and routes onward with the Navigate effect it was already emitting. The plan flagged this as the highest-risk step: type-safe routes resolve through generated kotlinx.serialization serializers, R8 can strip them, release is minified and the project's history includes a ProGuard incident. So it was gated on a real minified build rather than a debug one. The release APK was signed with the debug key and installed, and the mapping file confirms every NavRoute$$serializer survives. On device it walked Splash -> Onboarding -> Security gate -> Previews, created an entry, and navigated into Details - the one route carrying a serialized argument - with no crash. That run also incidentally confirmed the 5.7.0 credential slot end to end on a minified build: the device-credential prompt unwrapped the vault key and opened the database. Co-Authored-By: Claude Opus 5 (1M context) --- .../passkey/presentation/MainActivity.kt | 9 +-- .../presentation/navigation/NavGraph.kt | 76 +++++++------------ .../presentation/navigation/NavRoute.kt | 31 ++++++++ .../presentation/navigation/NavScreens.kt | 17 ----- .../passkey/presentation/navigation/Routes.kt | 10 --- .../screens/detail_screen/DetailViewModel.kt | 16 ++-- .../onboarding_screens/OnBoardingViewModel.kt | 4 +- .../preview_screen/PreviewViewModel.kt | 6 +- .../screens/settings_screen/SettingsPage.kt | 3 +- .../settings_screen/SettingsViewModel.kt | 6 +- .../screens/splash_screen/SplashViewModel.kt | 16 ++-- .../com/bhardwaj/passkey/utils/UiEvents.kt | 4 +- 12 files changed, 86 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavRoute.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavScreens.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/navigation/Routes.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt index 50af90d..d47faca 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation +import com.bhardwaj.passkey.presentation.navigation.NavRoute import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -17,7 +18,6 @@ import androidx.navigation.compose.rememberNavController import androidx.compose.runtime.LaunchedEffect import com.bhardwaj.passkey.data.security.AppLockObserver import com.bhardwaj.passkey.data.security.LockReason -import com.bhardwaj.passkey.presentation.navigation.Routes import kotlinx.coroutines.delay import com.bhardwaj.passkey.data.security.VaultSession import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashViewModel @@ -140,7 +140,7 @@ class MainActivity : FragmentActivity() { // ColdStart is the initial value and means "never unlocked yet", // so it must not yank the user out of onboarding. if (!isUnlocked && lockReason != LockReason.ColdStart) { - navController.navigate(Routes.SECURITY_PAGE) { + navController.navigate(NavRoute.Security) { // Drop every screen holding vault content, which also // destroys their nav-scoped ViewModels. popUpTo(0) { inclusive = true } @@ -148,10 +148,7 @@ class MainActivity : FragmentActivity() { } } - NavGraph( - navController = navController, - startDestination = splashViewModel.startDestination - ) + NavGraph(navController = navController) if (updateState == AppUpdateController.State.ReadyToInstall) { UpdateReadyBanner( modifier = Modifier.align(Alignment.BottomCenter), diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt index d5f9a14..e77280b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt @@ -2,72 +2,52 @@ package com.bhardwaj.passkey.presentation.navigation import androidx.compose.runtime.Composable import androidx.navigation.NavHostController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.navArgument import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailScreen import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingScreen import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewScreen import com.bhardwaj.passkey.presentation.screens.security_screen.VaultGateScreen import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsScreen import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashPage -import com.bhardwaj.passkey.domain.model.Category @Composable -fun NavGraph( - navController: NavHostController, - startDestination: String -) { - NavHost(navController = navController, startDestination = startDestination) { - composable(route = NavScreens.SplashPage.route) { - SplashPage( - onNavigate = { - navController.popBackStack() - navController.navigate(it.route) - } - ) +fun NavGraph(navController: NavHostController) { + // Splash is a constant start destination. It used to come from a mutable ViewModel field, + // and changing a NavHost's start destination after first composition recreates the graph and + // resets the back stack. Splash already routes onward with a Navigate effect. + NavHost(navController = navController, startDestination = NavRoute.Splash) { + + composable { + SplashPage(onNavigate = { navController.replaceWith(it.route) }) } - composable(route = NavScreens.OnboardingPage.route) { - OnBoardingScreen( - onNavigate = { - navController.popBackStack() - navController.navigate(it.route) - } - ) + + composable { + OnBoardingScreen(onNavigate = { navController.replaceWith(it.route) }) + } + + composable { + VaultGateScreen(onUnlocked = { navController.replaceWith(NavRoute.Previews) }) } - composable( - route = NavScreens.PreviewPage.route + "?categoryName={categoryName}", - arguments = listOf(navArgument(name = "categoryName") { - type = NavType.StringType - defaultValue = Category.BANKS.name - }), - ) { + + composable { PreviewScreen(onNavigate = { navController.navigate(it.route) }) } - composable( - route = NavScreens.DetailsPage.route + "?previewId={previewId}", - arguments = listOf(navArgument(name = "previewId") { - type = NavType.LongType - defaultValue = -1 - }) - ) { + + composable { DetailScreen(onPopBackStack = { navController.popBackStack() }) } - composable(route = NavScreens.SettingPage.route) { + + composable { SettingsScreen( onPopBackStack = { navController.popBackStack() }, - onNavigate = { route -> navController.navigate(route) }) - } - composable(route = NavScreens.SecurityPage.route) { - VaultGateScreen( - onUnlocked = { - navController.navigate(NavScreens.PreviewPage.route) { - // Nothing behind the gate should survive it. - popUpTo(0) { inclusive = true } - } - } + onNavigate = { navController.navigate(it) } ) } } -} \ No newline at end of file +} + +/** Navigates and drops everything behind it, for one-way transitions past a gate. */ +private fun NavHostController.replaceWith(route: NavRoute) { + navigate(route) { popUpTo(0) { inclusive = true } } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavRoute.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavRoute.kt new file mode 100644 index 0000000..71d29c8 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavRoute.kt @@ -0,0 +1,31 @@ +package com.bhardwaj.passkey.presentation.navigation + +import kotlinx.serialization.Serializable + +/** + * Type-safe navigation destinations. + * + * Replaces string constants that callers concatenated by hand - `Routes.DETAILS_PAGE + + * "?previewId=$id"` appeared in two ViewModels as independently maintained copies of one URL + * format, and nothing checked that the argument name matched the graph. + */ +sealed interface NavRoute { + + @Serializable + data object Splash : NavRoute + + @Serializable + data object Onboarding : NavRoute + + @Serializable + data object Security : NavRoute + + @Serializable + data object Previews : NavRoute + + @Serializable + data class Details(val previewId: Long) : NavRoute + + @Serializable + data object Settings : NavRoute +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavScreens.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavScreens.kt deleted file mode 100644 index ae95f6e..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavScreens.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.bhardwaj.passkey.presentation.navigation - -import com.bhardwaj.passkey.presentation.navigation.Routes.DETAILS_PAGE -import com.bhardwaj.passkey.presentation.navigation.Routes.ONBOARDING_PAGE -import com.bhardwaj.passkey.presentation.navigation.Routes.PREVIEW_PAGE -import com.bhardwaj.passkey.presentation.navigation.Routes.SECURITY_PAGE -import com.bhardwaj.passkey.presentation.navigation.Routes.SETTINGS_PAGE -import com.bhardwaj.passkey.presentation.navigation.Routes.SPLASH_PAGE - -sealed class NavScreens(val route: String) { - data object SplashPage : NavScreens(route = SPLASH_PAGE) - data object OnboardingPage : NavScreens(route = ONBOARDING_PAGE) - data object PreviewPage : NavScreens(route = PREVIEW_PAGE) - data object DetailsPage : NavScreens(route = DETAILS_PAGE) - data object SettingPage : NavScreens(route = SETTINGS_PAGE) - data object SecurityPage : NavScreens(route = SECURITY_PAGE) -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/Routes.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/Routes.kt deleted file mode 100644 index cb1afaf..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/Routes.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.bhardwaj.passkey.presentation.navigation - -object Routes { - const val SPLASH_PAGE = "splash_page" - const val ONBOARDING_PAGE = "onboarding_page" - const val PREVIEW_PAGE = "preview_page" - const val DETAILS_PAGE = "details_page" - const val SETTINGS_PAGE = "settings_page" - const val SECURITY_PAGE = "security_page" -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt index 38f2f77..c2b9d03 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt @@ -1,5 +1,7 @@ package com.bhardwaj.passkey.presentation.screens.detail_screen +import androidx.navigation.toRoute +import com.bhardwaj.passkey.presentation.navigation.NavRoute import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -53,7 +55,10 @@ class DetailViewModel @Inject constructor( val detailResponse = _detailResponse.asStateFlow() /** true = editing an existing row, false = adding. Resolved to text by the UI. */ val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) - val previewId = savedStateHandle.get("previewId") ?: -1 + // toRoute decodes the typed argument. The old form read a stringly-named key and fell back + // to -1, which is why a "something went wrong" branch existed below for an id that could + // never legitimately arrive. + val previewId: Long = savedStateHandle.toRoute().previewId private val _searchText = MutableStateFlow("") val searchText = _searchText.asStateFlow() @@ -146,15 +151,6 @@ class DetailViewModel @Inject constructor( ) return@launch } - if (previewId.toInt() == -1) { - isSheetOpen = false - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.something_went_wrong) - ) - ) - return@launch - } val question = detailTitle.value.trim() val answer = detailResponse.value.trim() diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt index e7d7b39..6400208 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt @@ -1,10 +1,10 @@ package com.bhardwaj.passkey.presentation.screens.onboarding_screens +import com.bhardwaj.passkey.presentation.navigation.NavRoute import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.domain.repository.PreferencesRepository import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingEvents -import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.utils.UiEvents import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -28,7 +28,7 @@ class OnBoardingViewModel @Inject constructor( is OnBoardingEvents.OnBoardingComplete -> { viewModelScope.launch { preferences.setOnboardingCompleted(true) - _uiEvents.send(UiEvents.Navigate(Routes.SECURITY_PAGE)) + _uiEvents.send(UiEvents.Navigate(NavRoute.Security)) } } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt index 4df9fc8..57c8a3b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.preview_screen +import com.bhardwaj.passkey.presentation.navigation.NavRoute import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,7 +11,6 @@ import com.bhardwaj.passkey.R import com.bhardwaj.passkey.domain.model.Preview import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents -import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_CATEGORY_NAME @@ -155,7 +155,7 @@ class PreviewViewModel @Inject constructor( is PreviewEvents.OnPreviewClick -> { _searchText.value = "" - sendUiEvents(UiEvents.Navigate(Routes.DETAILS_PAGE + "?previewId=${event.previewId}")) + sendUiEvents(UiEvents.Navigate(NavRoute.Details(previewId = event.previewId))) } is PreviewEvents.OnLongPress -> { @@ -164,7 +164,7 @@ class PreviewViewModel @Inject constructor( PreviewEvents.OnSettingsClick -> { _searchText.value = "" - sendUiEvents(UiEvents.Navigate(Routes.SETTINGS_PAGE)) + sendUiEvents(UiEvents.Navigate(NavRoute.Settings)) } is PreviewEvents.OnSwipedLeft -> { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index 1d0aebf..1b5b14e 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen +import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.domain.model.AutoLockTimeout import android.content.ContentResolver import android.content.Context @@ -71,7 +72,7 @@ import kotlinx.coroutines.launch @Composable fun SettingsScreen( onPopBackStack: () -> Unit, - onNavigate: (String) -> Unit, + onNavigate: (NavRoute) -> Unit, viewModel: SettingsViewModel = hiltViewModel() ) { val snackBarHostState = remember { SnackbarHostState() } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index 72cbdcc..8818d44 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen +import com.bhardwaj.passkey.presentation.navigation.NavRoute import android.app.Application import android.app.LocaleManager import android.content.Intent @@ -22,7 +23,6 @@ import com.bhardwaj.passkey.data.security.DatabaseKeyManager import com.bhardwaj.passkey.domain.repository.PreferencesRepository import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEvents -import com.bhardwaj.passkey.presentation.navigation.Routes import com.bhardwaj.passkey.utils.AlertBy.ABOUT import com.bhardwaj.passkey.utils.AlertBy.PRIVACY import com.bhardwaj.passkey.utils.AlertBy.TERMS_N_CONDITIONS @@ -263,9 +263,7 @@ class SettingsViewModel @Inject constructor( is SettingsEvents.OnAnalysisItemClick -> { isAnalysisSheetOpen = false sendUiEvents( - UiEvents.Navigate( - Routes.DETAILS_PAGE + "?previewId=${event.detail.previewId}" - ) + UiEvents.Navigate(NavRoute.Details(previewId = event.detail.previewId)) ) } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt index 8b55271..b631d9f 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt @@ -1,5 +1,6 @@ package com.bhardwaj.passkey.presentation.screens.splash_screen +import com.bhardwaj.passkey.presentation.navigation.NavRoute import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -8,7 +9,6 @@ import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.domain.repository.PreferencesRepository import kotlinx.coroutines.flow.first import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashEvents -import com.bhardwaj.passkey.presentation.navigation.NavScreens import com.bhardwaj.passkey.utils.UiEvents import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -21,9 +21,6 @@ class SplashViewModel @Inject constructor( private val preferences: PreferencesRepository ) : ViewModel() { - var startDestination by mutableStateOf(NavScreens.SplashPage.route) - private set - // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded // screen has no active collector, and a rendezvous channel would suspend the coroutine // that emitted the effect until the user came back. @@ -38,12 +35,11 @@ class SplashViewModel @Inject constructor( // it re-sent a Navigate effect on every later preference change - including a // language switch. val completed = preferences.onboardingCompleted.first() - startDestination = if (completed) { - NavScreens.SecurityPage.route - } else { - NavScreens.OnboardingPage.route - } - _uiEvents.send(UiEvents.Navigate(startDestination)) + _uiEvents.send( + UiEvents.Navigate( + if (completed) NavRoute.Security else NavRoute.Onboarding + ) + ) } } } diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt b/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt index 599fcd2..1642786 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt @@ -1,9 +1,11 @@ package com.bhardwaj.passkey.utils +import com.bhardwaj.passkey.presentation.navigation.NavRoute + sealed interface UiEvents { data object PopBackStack : UiEvents - data class Navigate(val route: String) : UiEvents + data class Navigate(val route: NavRoute) : UiEvents /** * Carries [UiText] rather than a resolved String so the message is localized at the call From b9a9a11117ab7abf845549a4a8d68215c4901abe Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 11:39:28 +0530 Subject: [PATCH 16/25] Convert the preview screen to single-state MVI, fixing the swipe-delete data loss The reference implementation for the pattern. Preview was chosen first of the real screens because it contains every pattern the codebase needs - Room-backed list, search filter, SavedStateHandle-persisted argument, bottom-sheet editor, confirm dialog, swipe-to-dismiss, drag-reorder and all three effect types - and because all three outstanding correctness bugs lived in it. State was previously spread across three SavedStateHandle flows, a MutableStateFlow, three `mutableStateOf` properties and a plain `var`. No single value described what the screen was showing, and impossible combinations were representable. There is now one PreviewState, one PreviewIntent, one PreviewEffect. State is *derived* from independent sources rather than accumulated. Combining the public state back into itself would be a self-feeding loop that only terminates because StateFlow deduplicates equal values. The data-loss bug is the reason this screen went first. Swiping deleted the row immediately and re-inserted it if the user cancelled, so a process death while the confirmation dialog was open lost the entry permanently. Swiping now only sets `pendingDelete`; the row is filtered out of the displayed list and nothing is written until the user confirms. Two unit tests assert the repository is untouched across swipe and cancel, and deletion now removes the entry and its details in one transaction - there is no foreign key cascade to do it. Modelling improvements that fell out of the single-state shape: - `editor: Editor?` collapses four fields - a sheet-open boolean, the sheet title, the heading being typed, and the row being edited. The title used to be stored as an already-resolved English string in SavedStateHandle, which was both a localization bug and a modelling one. - `isLoading` distinguishes "still loading" from "genuinely empty". stateIn emits its initial value first, so the empty-list illustration flashed on every entry. - intents carry plain indices; the old event type carried a Compose LazyListItemInfo. Also fixed the recomposition problem the plan identified. `onEvent = viewModel::onEvent` allocates a fresh object on every recomposition, so the parameter was never equal and every visible row recomposed on each keystroke in the search bar. It is now hoisted through `remember(viewModel) { viewModel::onIntent }`. 11 reducer tests, all plain JVM - no Robolectric - because effects carry unresolved UiText rather than resolved strings and the state is an ordinary data class. They run against a hand-written FakePasskeyRepository backed by StateFlows rather than a mock, since the assertions that matter are about what was *not* written. One test needed UnconfinedTestDispatcher rather than Standard: these assert on derived state immediately after dispatching an intent, and with a standard dispatcher nothing has run by the time the assertion executes. Verified on device: swipe shows the confirmation, cancel restores the entry, and no crash. 55 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/navigation/NavGraph.kt | 2 +- .../screens/preview_screen/PreviewContract.kt | 74 +++++ .../screens/preview_screen/PreviewEvents.kt | 35 -- .../screens/preview_screen/PreviewPage.kt | 79 +++-- .../preview_screen/PreviewViewModel.kt | 302 ++++++++---------- .../preview_screen/components/PreviewItem.kt | 13 +- .../bhardwaj/passkey/FakePasskeyRepository.kt | 128 ++++++++ .../bhardwaj/passkey/PreviewViewModelTest.kt | 231 ++++++++++++++ 8 files changed, 612 insertions(+), 252 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewContract.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/PreviewViewModelTest.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt index e77280b..b925439 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt @@ -31,7 +31,7 @@ fun NavGraph(navController: NavHostController) { } composable { - PreviewScreen(onNavigate = { navController.navigate(it.route) }) + PreviewScreen(onNavigate = { navController.navigate(it) }) } composable { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewContract.kt new file mode 100644 index 0000000..3c3a0a4 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewContract.kt @@ -0,0 +1,74 @@ +package com.bhardwaj.passkey.presentation.screens.preview_screen + +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.presentation.navigation.NavRoute +import com.bhardwaj.passkey.utils.UiText + +/** + * The reference MVI contract for this app: one immutable state object, one intent type, one + * effect type. + * + * Previously the screen read from a bag of separate sources - three SavedStateHandle flows, a + * MutableStateFlow, three `mutableStateOf` properties and a plain `var` - which meant no single + * value described what the screen was showing, and impossible combinations were representable. + */ +data class PreviewState( + val category: Category = Category.BANKS, + val query: String = "", + val items: List = emptyList(), + /** + * Distinguishes "still loading" from "genuinely empty". `stateIn` emits its initial value + * first, so without this the empty-list illustration flashed on every entry to the screen. + */ + val isLoading: Boolean = true, + /** Non-null means the add/edit sheet is open. */ + val editor: Editor? = null, + /** + * Non-null means the delete confirmation is showing. The row is hidden from [items] but is + * **not** deleted until confirmed - see PreviewViewModel. + */ + val pendingDelete: Preview? = null +) { + /** + * Collapses four previously separate fields: a sheet-open boolean, the sheet's title, the + * heading being typed, and the row being edited. The title was stored as an already-resolved + * English string, which was both a localization bug and a modelling one. + */ + data class Editor( + val heading: String = "", + val editingId: Long? = null + ) { + val isEdit: Boolean get() = editingId != null + } + + val showEmptyState: Boolean get() = !isLoading && items.isEmpty() && query.isBlank() +} + +sealed interface PreviewIntent { + data class CategorySelected(val category: Category) : PreviewIntent + data class QueryChanged(val query: String) : PreviewIntent + + data object AddClicked : PreviewIntent + data class EditClicked(val preview: Preview) : PreviewIntent + data class HeadingChanged(val heading: String) : PreviewIntent + data object SaveClicked : PreviewIntent + data object EditorDismissed : PreviewIntent + + data class SwipedToDelete(val preview: Preview) : PreviewIntent + data object DeleteConfirmed : PreviewIntent + data object DeleteCancelled : PreviewIntent + + /** Plain indices: the old event carried a Compose `LazyListItemInfo`. */ + data class Moved(val fromIndex: Int, val toIndex: Int) : PreviewIntent + + data class ItemClicked(val previewId: Long) : PreviewIntent + data class ItemLongPressed(val heading: String) : PreviewIntent + data object SettingsClicked : PreviewIntent +} + +sealed interface PreviewEffect { + data class ShowSnackbar(val message: UiText) : PreviewEffect + data class Navigate(val route: NavRoute) : PreviewEffect + data class CopyToClipboard(val value: String, val isSensitive: Boolean) : PreviewEffect +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt deleted file mode 100644 index df6908f..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewEvents.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.bhardwaj.passkey.presentation.screens.preview_screen - -import androidx.compose.foundation.lazy.LazyListItemInfo -import com.bhardwaj.passkey.domain.model.Preview - -sealed interface PreviewEvents { - // Dialogs. - data class OnHeadingChange(val newHeading: String) : PreviewEvents - data object OnSaveClick : PreviewEvents - data object OnCancelClick : PreviewEvents - data object OnDeleteClick : PreviewEvents - data object OnDismissBottomSheet : PreviewEvents - data object OnDismissAlertDialog : PreviewEvents - - // Single Item. - data class OnLongPress(val previewHeading: String) : PreviewEvents - data class OnPreviewClick(val previewId: Long) : PreviewEvents - data class OnReorderPreview(val from: LazyListItemInfo, val to: LazyListItemInfo) : - PreviewEvents - - data class OnChangeClick(val preview: Preview) : PreviewEvents - data class OnSwipedLeft(val preview: Preview) : PreviewEvents - - // Floating Action Button - data object OnAddPreviewClick : PreviewEvents - - // Search - data class OnSearchTextUpdate(val newText: String) : PreviewEvents - - // Bottom Navigation. - data class OnBottomNavigationClick(val newTitle: String) : PreviewEvents - - // Settings - data object OnSettingsClick : PreviewEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index 5d320c0..c753453 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -59,9 +59,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents +import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.R import com.bhardwaj.passkey.presentation.screens.common.labelRes -import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewViewModel import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PasskeySearchBar @@ -82,21 +82,17 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalFoundationApi::class) @Composable fun PreviewScreen( - onNavigate: (UiEvents.Navigate) -> Unit, + onNavigate: (NavRoute) -> Unit, viewModel: PreviewViewModel = hiltViewModel() ) { - val categoryName by viewModel.categoryName.collectAsStateWithLifecycle() // Localized display names come from Category.labelRes, which the bottom navigation // also uses, so the two can no longer drift apart. val categoryNameMap = Category.entries.associate { it.name to stringResource(it.labelRes()) } - val previewHeading by viewModel.previewHeading.collectAsStateWithLifecycle() - val isEditingSheet by viewModel.isEditingSheet.collectAsStateWithLifecycle() - // Resolved here rather than in the ViewModel so it follows the app locale. - val bottomSheetHeading = - stringResource(if (isEditingSheet) R.string.edit else R.string.add) - val searchText by viewModel.searchText.collectAsStateWithLifecycle() - val previews by viewModel.previews.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + // A bound method reference allocates a new object on every recomposition, so the lambda + // parameter was never equal and every visible row recomposed on each keystroke. + val onIntent = remember(viewModel) { viewModel::onIntent } var selectedIndex by rememberSaveable { mutableIntStateOf(0) } @@ -105,7 +101,7 @@ fun PreviewScreen( val view = LocalView.current val lazyListState = rememberLazyListState() val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to -> - viewModel.onEvent(PreviewEvents.OnReorderPreview(from, to)) + onIntent(PreviewIntent.Moved(from.index, to.index)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) } @@ -113,18 +109,17 @@ fun PreviewScreen( val context = LocalContext.current - ObserveAsEvents(viewModel.uiEvents) { event -> + ObserveAsEvents(viewModel.effects) { event -> when (event) { - is UiEvents.Navigate -> onNavigate(event) - is UiEvents.ShowSnackBar -> { + is PreviewEffect.Navigate -> onNavigate(event.route) + is PreviewEffect.ShowSnackbar -> { scope.launch { snackBarHostState.showSnackbar( - message = event.message.asString(context), - actionLabel = event.action?.asString(context) + message = event.message.asString(context) ) } } - is UiEvents.CopyToClipboard -> { + is PreviewEffect.CopyToClipboard -> { SecureClipboard.copy( context = context, text = event.value, @@ -154,7 +149,7 @@ fun PreviewScreen( MainBottomNavigation( selectedIndex = selectedIndex, onItemClick = { newIndex, newTitle -> - viewModel.onEvent(PreviewEvents.OnBottomNavigationClick(newTitle)) + onIntent(PreviewIntent.CategorySelected(Category.valueOf(newTitle))) selectedIndex = newIndex } ) @@ -164,7 +159,7 @@ fun PreviewScreen( floatingActionButton = { FloatingActionButton( modifier = Modifier.offset(y = 64.dp), - onClick = { viewModel.onEvent(PreviewEvents.OnAddPreviewClick) }, + onClick = { onIntent(PreviewIntent.AddClicked) }, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, content = { @@ -203,11 +198,11 @@ fun PreviewScreen( .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, - onClick = { viewModel.onEvent(PreviewEvents.OnSettingsClick) } + onClick = { onIntent(PreviewIntent.SettingsClicked) } ) ) } - categoryNameMap[categoryName]?.let { + categoryNameMap[state.category.name]?.let { Text( text = it, fontFamily = BebasNeue, @@ -216,20 +211,20 @@ fun PreviewScreen( ) } - if (searchText.isNotBlank() || previews.isNotEmpty()) { + if (state.query.isNotBlank() || state.items.isNotEmpty()) { PasskeySearchBar( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 16.dp), - query = searchText, - onQueryChange = { viewModel.onEvent(PreviewEvents.OnSearchTextUpdate(newText = it)) }, + query = state.query, + onQueryChange = { onIntent(PreviewIntent.QueryChanged(it)) }, onTrailingIconClick = { - viewModel.onEvent(PreviewEvents.OnSearchTextUpdate(newText = "")) + onIntent(PreviewIntent.QueryChanged("")) } ) } - if (previews.isEmpty()) { + if (state.showEmptyState) { Image( modifier = Modifier .weight(1F) @@ -243,13 +238,13 @@ fun PreviewScreen( state = lazyListState, ) { items( - items = previews, + items = state.items, key = { item -> item.id } ) { preview -> - if (searchText.isNotBlank()) { + if (state.query.isNotBlank()) { PreviewItem( preview = preview, - onEvent = viewModel::onEvent + onIntent = onIntent ) } else { val state = rememberSwipeToDismissBoxState( @@ -259,7 +254,7 @@ fun PreviewScreen( LaunchedEffect(state.currentValue) { if (state.currentValue == SwipeToDismissBoxValue.EndToStart) { - viewModel.onEvent(PreviewEvents.OnSwipedLeft(preview)) + onIntent(PreviewIntent.SwipedToDelete(preview)) state.snapTo(SwipeToDismissBoxValue.Settled) } } @@ -290,7 +285,7 @@ fun PreviewScreen( PreviewItem( scope = this@ReorderableItem, preview = preview, - onEvent = viewModel::onEvent + onIntent = onIntent ) }, enableDismissFromEndToStart = true, @@ -302,35 +297,37 @@ fun PreviewScreen( } } } - if (viewModel.isSheetOpen) { + state.editor?.let { editor -> PreviewBottomSheet( - bottomSheetHeading = bottomSheetHeading, - previewHeading = previewHeading, + bottomSheetHeading = stringResource( + if (editor.isEdit) R.string.edit else R.string.add + ), + previewHeading = editor.heading, onDismiss = { - viewModel.onEvent(PreviewEvents.OnDismissBottomSheet) + onIntent(PreviewIntent.EditorDismissed) }, onHeadingChange = { - viewModel.onEvent(PreviewEvents.OnHeadingChange(it)) + onIntent(PreviewIntent.HeadingChanged(it)) }, onSave = { - viewModel.onEvent(PreviewEvents.OnSaveClick) + onIntent(PreviewIntent.SaveClicked) } ) } } - if (viewModel.isAlertOpen) { + if (state.pendingDelete != null) { AlertDialog( properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { - viewModel.onEvent(PreviewEvents.OnDismissAlertDialog) + onIntent(PreviewIntent.DeleteCancelled) }, title = { Text(text = stringResource(id = R.string.confirm_delete_title)) }, text = { Text(text = stringResource(id = R.string.confirm_delete_description_heading)) }, confirmButton = { PassKeyButton( onClick = { - viewModel.onEvent(PreviewEvents.OnDeleteClick) + onIntent(PreviewIntent.DeleteConfirmed) }, text = stringResource(id = R.string.delete), buttonType = ButtonType.DEFAULT @@ -339,7 +336,7 @@ fun PreviewScreen( dismissButton = { PassKeyButton( onClick = { - viewModel.onEvent(PreviewEvents.OnCancelClick) + onIntent(PreviewIntent.DeleteCancelled) }, text = stringResource(id = R.string.cancel), buttonType = ButtonType.OUTLINED diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt index 57c8a3b..887c8a6 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewViewModel.kt @@ -1,21 +1,14 @@ package com.bhardwaj.passkey.presentation.screens.preview_screen -import com.bhardwaj.passkey.presentation.navigation.NavRoute -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.domain.model.Preview import com.bhardwaj.passkey.domain.repository.PasskeyRepository -import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents -import com.bhardwaj.passkey.domain.model.Category -import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING +import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_CATEGORY_NAME -import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_HEADING -import com.bhardwaj.passkey.utils.UiEvents import com.bhardwaj.passkey.utils.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,7 +16,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow @@ -36,201 +28,173 @@ class PreviewViewModel @Inject constructor( private val repository: PasskeyRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { - // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded - // screen has no active collector, and a rendezvous channel would suspend the coroutine - // that emitted the effect until the user came back. - private val _uiEvents = Channel(Channel.BUFFERED) - val uiEvents = _uiEvents.receiveAsFlow() - val categoryName = savedStateHandle.getStateFlow(PREVIEW_CATEGORY_NAME, Category.BANKS.name) - val previewHeading = savedStateHandle.getStateFlow(PREVIEW_HEADING, "") - /** true = editing an existing row, false = adding. Resolved to text by the UI. */ - val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) - - private val _searchText = MutableStateFlow("") - val searchText = _searchText.asStateFlow() - - @OptIn(ExperimentalCoroutinesApi::class) - val previews: StateFlow> = categoryName - // Filtered in SQL now, so reordering renumbers within a single category instead of a - // list that had already been narrowed in memory. - .flatMapLatest { name -> repository.getPreviewsByCategory(Category.valueOf(name)) } - .combine(searchText) { previews, text -> - if (text.isBlank()) previews - else previews.filter { it.heading.contains(text, ignoreCase = true) } - } - // WhileSubscribed, not Lazily: Lazily keeps the Room invalidation observer alive for the - // ViewModel's entire life even while the screen is backgrounded. - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + // BUFFERED: with lifecycle-aware collection a backgrounded screen has no active collector, + // and a rendezvous channel would suspend whoever emitted the effect. + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() - var isSheetOpen by mutableStateOf(false) - private set + /** Survives process death, unlike the rest of the UI state. */ + private val category = savedStateHandle.getStateFlow(PREVIEW_CATEGORY_NAME, Category.BANKS.name) - var isAlertOpen by mutableStateOf(false) - private set + private val query = MutableStateFlow("") - var preview by mutableStateOf(null) - private set + /** Editor and pending-delete only; the rest of the state is derived. */ + private val transient = MutableStateFlow(TransientState()) - private var deletedPreview: Preview? = null + private data class TransientState( + val editor: PreviewState.Editor? = null, + val pendingDelete: Preview? = null + ) - fun onEvent(event: PreviewEvents) { - when (event) { - is PreviewEvents.OnBottomNavigationClick -> { - if (categoryName.value != event.newTitle) { - savedStateHandle[PREVIEW_CATEGORY_NAME] = event.newTitle - _searchText.value = "" + /** + * State is *derived* from independent sources rather than accumulated into one mutable + * object. Combining the public state back into itself would be a self-feeding loop that only + * terminates because StateFlow deduplicates equal values. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow = combine( + category, + query, + transient, + category.flatMapLatest { repository.getPreviewsByCategory(Category.valueOf(it)) } + ) { categoryName, currentQuery, ui, items -> + PreviewState( + category = Category.valueOf(categoryName), + query = currentQuery, + items = items + .filter { currentQuery.isBlank() || it.heading.contains(currentQuery, true) } + // Hidden rather than deleted while the confirmation is up. + .filterNot { it.id == ui.pendingDelete?.id }, + isLoading = false, + editor = ui.editor, + pendingDelete = ui.pendingDelete + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PreviewState()) + + fun onIntent(intent: PreviewIntent) { + when (intent) { + is PreviewIntent.CategorySelected -> { + if (category.value != intent.category.name) { + savedStateHandle[PREVIEW_CATEGORY_NAME] = intent.category.name + query.value = "" } } - PreviewEvents.OnAddPreviewClick -> { - savedStateHandle[BOTTOM_SHEET_HEADING] = false - isSheetOpen = true - _searchText.value = "" - } + is PreviewIntent.QueryChanged -> query.value = intent.query - PreviewEvents.OnDismissBottomSheet -> { - savedStateHandle[PREVIEW_HEADING] = "" - isSheetOpen = false + PreviewIntent.AddClicked -> { + query.value = "" + transient.update { it.copy(editor = PreviewState.Editor()) } } - is PreviewEvents.OnChangeClick -> { - viewModelScope.launch { - _searchText.value = "" - repository.getPreviewById(event.preview.id)?.let { preview -> - isSheetOpen = true - savedStateHandle[BOTTOM_SHEET_HEADING] = true - savedStateHandle[PREVIEW_HEADING] = event.preview.heading - this@PreviewViewModel.preview = preview - } + is PreviewIntent.EditClicked -> { + query.value = "" + transient.update { + it.copy( + editor = PreviewState.Editor( + heading = intent.preview.heading, + editingId = intent.preview.id + ) + ) } } - is PreviewEvents.OnHeadingChange -> { - savedStateHandle[PREVIEW_HEADING] = event.newHeading - } + is PreviewIntent.HeadingChanged -> + transient.update { it.copy(editor = it.editor?.copy(heading = intent.heading)) } - is PreviewEvents.OnSaveClick -> { - viewModelScope.launch { - if (previewHeading.value.isBlank()) { - isSheetOpen = false - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.enter_valid_heading) - ) - ) - return@launch - } + PreviewIntent.EditorDismissed -> transient.update { it.copy(editor = null) } - val heading = previewHeading.value.trim() - val category = Category.valueOf(categoryName.value) - - val existingPreview = repository.getPreviewByHeading(heading, category) - // When editing, the lookup finds the very row being edited. Treating that as - // a clash meant saving an edit without renaming it reported "heading exists" - // and silently discarded the edit. - val isClashWithAnotherRow = - existingPreview != null && existingPreview.id != preview?.id - if (isClashWithAnotherRow) { - preview = null - savedStateHandle[PREVIEW_HEADING] = "" - isSheetOpen = false - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.heading_exists) - ) - ) - return@launch - } + PreviewIntent.SaveClicked -> save() - preview?.let { - repository.updatePreview(it.copy(heading = heading, category = category)) - } ?: repository.createPreview(heading = heading, category = category) + is PreviewIntent.SwipedToDelete -> + // Nothing is written yet. The previous implementation deleted the row here and + // re-inserted it on cancel, so a process death while the dialog was open lost it. + transient.update { it.copy(pendingDelete = intent.preview) } - preview = null - savedStateHandle[PREVIEW_HEADING] = "" - isSheetOpen = false - } - } - - is PreviewEvents.OnPreviewClick -> { - _searchText.value = "" - sendUiEvents(UiEvents.Navigate(NavRoute.Details(previewId = event.previewId))) - } - - is PreviewEvents.OnLongPress -> { - sendUiEvents(UiEvents.CopyToClipboard(value = event.previewHeading, isSensitive = false)) - } - - PreviewEvents.OnSettingsClick -> { - _searchText.value = "" - sendUiEvents(UiEvents.Navigate(NavRoute.Settings)) - } + PreviewIntent.DeleteCancelled -> transient.update { it.copy(pendingDelete = null) } - is PreviewEvents.OnSwipedLeft -> { + PreviewIntent.DeleteConfirmed -> { + val target = transient.value.pendingDelete ?: return viewModelScope.launch { - isAlertOpen = true - repository.deletePreview(event.preview) - deletedPreview = event.preview + repository.runInTransaction { + repository.deleteDetailsByPreviewId(target.id) + repository.deletePreview(target) + } + transient.update { it.copy(pendingDelete = null) } } } - PreviewEvents.OnDismissAlertDialog -> { - viewModelScope.launch { - isAlertOpen = false - deletedPreview?.let { repository.updatePreview(it) } - deletedPreview = null - } - } + is PreviewIntent.Moved -> move(intent.fromIndex, intent.toIndex) - PreviewEvents.OnCancelClick -> { - viewModelScope.launch { - isAlertOpen = false - deletedPreview?.let { repository.updatePreview(it) } - deletedPreview = null - } + is PreviewIntent.ItemClicked -> { + query.value = "" + emit(PreviewEffect.Navigate(NavRoute.Details(intent.previewId))) } - is PreviewEvents.OnDeleteClick -> { - viewModelScope.launch { - deletedPreview?.let { repository.deleteDetailsByPreviewId(it.id) } - isAlertOpen = false - deletedPreview = null - } - } + is PreviewIntent.ItemLongPressed -> + // A heading is not a secret, so it is not marked sensitive and is not auto-cleared. + emit(PreviewEffect.CopyToClipboard(intent.heading, isSensitive = false)) - is PreviewEvents.OnSearchTextUpdate -> { - _searchText.value = event.newText + PreviewIntent.SettingsClicked -> { + query.value = "" + emit(PreviewEffect.Navigate(NavRoute.Settings)) } + } + } - is PreviewEvents.OnReorderPreview -> { - viewModelScope.launch { - val newList = previews.value.toMutableList().apply { - add(event.to.index, removeAt(event.from.index)) - } - - val updatedList = newList.mapIndexed { index, preview -> - preview.copy(sequence = index.toLong()) - } + private fun save() { + val editor = transient.value.editor ?: return + val heading = editor.heading.trim() + if (heading.isBlank()) { + transient.update { it.copy(editor = null) } + emit(PreviewEffect.ShowSnackbar(UiText.StringResource(R.string.enter_valid_heading))) + return + } + viewModelScope.launch { + val currentCategory = Category.valueOf(category.value) + val existing = repository.getPreviewByHeading(heading, currentCategory) + // When editing, the lookup finds the very row being edited. Treating that as a clash + // meant saving an edit without renaming it reported "heading exists" and silently + // discarded the edit. + if (existing != null && existing.id != editor.editingId) { + transient.update { it.copy(editor = null) } + emit(PreviewEffect.ShowSnackbar(UiText.StringResource(R.string.heading_exists))) + return@launch + } - // One transaction, not N separate writes: a reorder interrupted midway - // used to leave the list in a partially renumbered state. - repository.runInTransaction { - updatedList.forEach { preview -> - repository.updatePreviewSequence( - previewId = preview.id, - sequence = preview.sequence - ) - } - } + if (editor.editingId != null) { + repository.getPreviewById(editor.editingId)?.let { existingRow -> + repository.updatePreview(existingRow.copy(heading = heading)) } + } else { + repository.createPreview(heading = heading, category = currentCategory) } + transient.update { it.copy(editor = null) } } } - private fun sendUiEvents(events: UiEvents) { + private fun move(fromIndex: Int, toIndex: Int) { + val current = state.value.items + if (fromIndex !in current.indices || toIndex !in current.indices) return + val reordered = current.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } viewModelScope.launch { - _uiEvents.send(events) + // One transaction, not N separate writes: an interrupted reorder used to leave the + // list partially renumbered. + repository.runInTransaction { + reordered.forEachIndexed { index, preview -> + repository.updatePreviewSequence(preview.id, index.toLong()) + } + } } } -} \ No newline at end of file + + private fun emit(effect: PreviewEffect) { + viewModelScope.launch { _effects.send(effect) } + } +} + +/** Local helper so intents read as transformations rather than assignments. */ +private inline fun MutableStateFlow.update(transform: (T) -> T) { + value = transform(value) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt index 9e01057..90e2485 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewItem.kt @@ -1,5 +1,7 @@ package com.bhardwaj.passkey.presentation.screens.preview_screen.components +import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewIntent import android.os.Build import android.view.HapticFeedbackConstants import androidx.compose.foundation.background @@ -31,7 +33,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEvents import com.bhardwaj.passkey.presentation.theme.Poppins import sh.calvin.reorderable.ReorderableCollectionItemScope import com.bhardwaj.passkey.domain.model.Preview as PreviewEntity @@ -40,8 +41,8 @@ import com.bhardwaj.passkey.domain.model.Preview as PreviewEntity fun PreviewItem( modifier: Modifier = Modifier, scope: ReorderableCollectionItemScope? = null, - preview: PreviewEntity, - onEvent: (PreviewEvents) -> Unit, + preview: Preview, + onIntent: (PreviewIntent) -> Unit, ) { val view = LocalView.current Box(modifier = modifier.fillMaxWidth()) { @@ -87,10 +88,10 @@ fun PreviewItem( .weight(1F) .combinedClickable( onClick = { - onEvent(PreviewEvents.OnPreviewClick(preview.id)) + onIntent(PreviewIntent.ItemClicked(preview.id)) }, onLongClick = { - onEvent(PreviewEvents.OnLongPress(preview.heading)) + onIntent(PreviewIntent.ItemLongPressed(preview.heading)) }, indication = null, interactionSource = remember { MutableInteractionSource() } @@ -106,7 +107,7 @@ fun PreviewItem( Icon( modifier = Modifier.clickable( onClick = { - onEvent(PreviewEvents.OnChangeClick(preview)) + onIntent(PreviewIntent.EditClicked(preview)) }, indication = null, interactionSource = remember { MutableInteractionSource() } diff --git a/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt b/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt new file mode 100644 index 0000000..0f6daa9 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt @@ -0,0 +1,128 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory repository backed by StateFlows. + * + * A hand-written fake rather than a mock: these methods return flows that tests need to emit on, + * and assertions about what was *not* written are clearer against real stored state than against + * verify-never calls. + */ +class FakePasskeyRepository : PasskeyRepository { + + private val previews = MutableStateFlow>(emptyList()) + private val details = MutableStateFlow>(emptyList()) + private var nextId = 1L + + /** Counts writes so tests can assert nothing touched the database. */ + var writeCount = 0 + private set + + fun seedPreviews(vararg items: Preview) { + previews.value = items.toList() + nextId = (items.maxOfOrNull { it.id } ?: 0L) + 1 + } + + fun currentPreviews(): List = previews.value + fun currentDetails(): List = details.value + + override fun getPreviews(): Flow> = previews + + override fun getPreviewsByCategory(category: Category): Flow> = + previews.map { list -> list.filter { it.category == category }.sortedBy { it.sequence } } + + override suspend fun getPreviewById(previewId: Long): Preview? = + previews.value.firstOrNull { it.id == previewId } + + override suspend fun getPreviewByHeading(heading: String, category: Category): Preview? = + previews.value.firstOrNull { + it.heading.equals(heading, ignoreCase = true) && it.category == category + } + + override suspend fun createPreview(heading: String, category: Category, sequence: Long): Long { + writeCount++ + val id = nextId++ + previews.value += Preview(id, heading, category, sequence) + return id + } + + override suspend fun updatePreview(preview: Preview) { + writeCount++ + previews.value = previews.value.map { if (it.id == preview.id) preview else it } + } + + override suspend fun deletePreview(preview: Preview) { + writeCount++ + previews.value = previews.value.filterNot { it.id == preview.id } + } + + override suspend fun updatePreviewSequence(previewId: Long, sequence: Long) { + writeCount++ + previews.value = previews.value.map { + if (it.id == previewId) it.copy(sequence = sequence) else it + } + } + + override fun getDetails(): Flow> = details + + override fun getDetailsByPreviewId(previewId: Long): Flow> = + details.map { list -> list.filter { it.previewId == previewId }.sortedBy { it.sequence } } + + override suspend fun getDetailByContent( + previewId: Long, + question: String, + answer: String + ): Detail? = details.value.firstOrNull { + it.previewId == previewId && it.question == question && it.answer == answer + } + + override suspend fun createDetail( + previewId: Long, + question: String, + answer: String, + sequence: Long, + isSecret: Boolean + ): Long { + writeCount++ + val id = nextId++ + details.value += Detail(id, previewId, question, answer, sequence, isSecret) + return id + } + + override suspend fun updateDetail(detail: Detail) { + writeCount++ + details.value = details.value.map { if (it.id == detail.id) detail else it } + } + + override suspend fun deleteDetail(detail: Detail) { + writeCount++ + details.value = details.value.filterNot { it.id == detail.id } + } + + override suspend fun deleteDetailsByPreviewId(previewId: Long) { + writeCount++ + details.value = details.value.filterNot { it.previewId == previewId } + } + + override suspend fun updateDetailSequence(detailId: Long, sequence: Long) { + writeCount++ + details.value = details.value.map { + if (it.id == detailId) it.copy(sequence = sequence) else it + } + } + + override suspend fun runInTransaction(block: suspend () -> R): R = block() + + override suspend fun deleteAll() { + writeCount++ + previews.value = emptyList() + details.value = emptyList() + } +} diff --git a/app/src/test/java/com/bhardwaj/passkey/PreviewViewModelTest.kt b/app/src/test/java/com/bhardwaj/passkey/PreviewViewModelTest.kt new file mode 100644 index 0000000..5a7a6a7 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/PreviewViewModelTest.kt @@ -0,0 +1,231 @@ +package com.bhardwaj.passkey + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.presentation.navigation.NavRoute +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewEffect +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewIntent +import com.bhardwaj.passkey.presentation.screens.preview_screen.PreviewViewModel +import com.bhardwaj.passkey.utils.UiText +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * Reducer tests for the reference MVI screen. + * + * These are plain JVM tests: nothing here touches Android, because effects carry unresolved + * UiText rather than resolved strings, and the state is an ordinary data class. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PreviewViewModelTest { + + // Unconfined rather than Standard: these assert on derived state after dispatching an + // intent, and with a standard dispatcher nothing has run by the time the assertion executes. + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var repository: FakePasskeyRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = FakePasskeyRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = PreviewViewModel(repository, SavedStateHandle()) + + private fun preview(id: Long, heading: String, sequence: Long = 0) = + Preview(id = id, heading = heading, category = Category.BANKS, sequence = sequence) + + @Test + fun `swiping to delete hides the row but writes nothing`() = runTest(dispatcher) { + // The regression this release exists for: the old implementation deleted immediately and + // re-inserted on cancel, so a process death while the dialog was open lost the entry. + repository.seedPreviews(preview(1, "Bank"), preview(2, "Mail")) + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(PreviewIntent.SwipedToDelete(preview(1, "Bank"))) + val state = expectMostRecentItem() + + assertThat(state.pendingDelete?.id).isEqualTo(1L) + assertThat(state.items.map { it.heading }).containsExactly("Mail") + cancelAndIgnoreRemainingEvents() + } + + assertThat(repository.writeCount).isEqualTo(0) + assertThat(repository.currentPreviews()).hasSize(2) + } + + @Test + fun `cancelling a delete restores the row and still writes nothing`() = runTest(dispatcher) { + repository.seedPreviews(preview(1, "Bank")) + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(PreviewIntent.SwipedToDelete(preview(1, "Bank"))) + vm.onIntent(PreviewIntent.DeleteCancelled) + val state = expectMostRecentItem() + + assertThat(state.pendingDelete).isNull() + assertThat(state.items.map { it.heading }).containsExactly("Bank") + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.writeCount).isEqualTo(0) + } + + @Test + fun `confirming a delete removes the entry and its details`() = runTest(dispatcher) { + repository.seedPreviews(preview(1, "Bank")) + repository.createDetail(previewId = 1, question = "Password", answer = "secret") + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(PreviewIntent.SwipedToDelete(preview(1, "Bank"))) + vm.onIntent(PreviewIntent.DeleteConfirmed) + expectMostRecentItem() + cancelAndIgnoreRemainingEvents() + } + + assertThat(repository.currentPreviews()).isEmpty() + // Orphaned details would otherwise linger: there is no foreign key cascade. + assertThat(repository.currentDetails()).isEmpty() + } + + @Test + fun `saving an edit without renaming does not report a duplicate`() = runTest(dispatcher) { + // The lookup finds the very row being edited; treating that as a clash used to report + // "heading exists" and silently discard the edit. + repository.seedPreviews(preview(1, "Bank")) + val vm = viewModel() + + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(PreviewIntent.EditClicked(preview(1, "Bank"))) + vm.onIntent(PreviewIntent.SaveClicked) + expectNoEvents() + } + assertThat(repository.currentPreviews().single().heading).isEqualTo("Bank") + } + + @Test + fun `saving a heading that clashes with another row is rejected`() = runTest(dispatcher) { + repository.seedPreviews(preview(1, "Bank"), preview(2, "Mail")) + val vm = viewModel() + + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(PreviewIntent.EditClicked(preview(2, "Mail"))) + vm.onIntent(PreviewIntent.HeadingChanged("Bank")) + vm.onIntent(PreviewIntent.SaveClicked) + + val effect = awaitItem() + assertThat(effect).isEqualTo( + PreviewEffect.ShowSnackbar(UiText.StringResource(R.string.heading_exists)) + ) + } + } + + @Test + fun `saving a blank heading is rejected without writing`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(PreviewIntent.AddClicked) + vm.onIntent(PreviewIntent.HeadingChanged(" ")) + vm.onIntent(PreviewIntent.SaveClicked) + + assertThat(awaitItem()).isEqualTo( + PreviewEffect.ShowSnackbar(UiText.StringResource(R.string.enter_valid_heading)) + ) + } + assertThat(repository.writeCount).isEqualTo(0) + } + + @Test + fun `adding trims the heading`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(PreviewIntent.AddClicked) + vm.onIntent(PreviewIntent.HeadingChanged(" Bank ")) + vm.onIntent(PreviewIntent.SaveClicked) + expectMostRecentItem() + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.currentPreviews().single().heading).isEqualTo("Bank") + } + + @Test + fun `query filters without touching the database`() = runTest(dispatcher) { + repository.seedPreviews(preview(1, "Bank"), preview(2, "Mail")) + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(PreviewIntent.QueryChanged("ban")) + assertThat(expectMostRecentItem().items.map { it.heading }).containsExactly("Bank") + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.writeCount).isEqualTo(0) + } + + @Test + fun `reordering renumbers sequences within the category`() = runTest(dispatcher) { + repository.seedPreviews( + preview(1, "A", sequence = 0), + preview(2, "B", sequence = 1), + preview(3, "C", sequence = 2) + ) + val vm = viewModel() + + vm.state.test { + // The state flow is WhileSubscribed, so it needs a live collector before the + // reorder can read the current ordering. + awaitItem() + vm.onIntent(PreviewIntent.Moved(fromIndex = 0, toIndex = 2)) + cancelAndIgnoreRemainingEvents() + } + + val ordered = repository.currentPreviews().sortedBy { it.sequence }.map { it.heading } + assertThat(ordered).containsExactly("B", "C", "A").inOrder() + assertThat(repository.currentPreviews().map { it.sequence }.sorted()) + .containsExactly(0L, 1L, 2L).inOrder() + } + + @Test + fun `tapping an entry navigates with its id`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(PreviewIntent.ItemClicked(previewId = 7)) + assertThat(awaitItem()).isEqualTo(PreviewEffect.Navigate(NavRoute.Details(7))) + } + } + + @Test + fun `long pressing a heading copies it as non-sensitive`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(PreviewIntent.ItemLongPressed("Bank")) + // A heading is not a secret, so it must not be marked sensitive or auto-cleared. + assertThat(awaitItem()) + .isEqualTo(PreviewEffect.CopyToClipboard("Bank", isSensitive = false)) + } + } +} From 35c665cf6c9ed685de6f8ff1fb78dfe98a7ec869 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 11:51:45 +0530 Subject: [PATCH 17/25] Convert the detail screen to single-state MVI and fold in the password policy Follows the Preview pattern. Detail carried the same swipe-delete data-loss bug - the row was deleted immediately and re-inserted on cancel, so a process death while the confirmation dialog was open lost it permanently. Swiping now only marks the row pending; nothing is written until the user confirms. Two tests assert the repository is untouched across swipe and cancel. The password generator's five loose `mutableStateOf` fields become a PasswordPolicy domain value object, and the toggle intent is typed. It previously dispatched on the magic strings "Upper", "Lower", "Number" and "Special" - a typo in either the sheet or the ViewModel would have silently done nothing. Turning off the last remaining character class now falls back to lowercase in the state itself, so "every switch off" is unrepresentable rather than merely guarded inside the generator. Trimming is applied on both the create and edit paths. Only create trimmed before, so a trailing space crept into stored passwords on edit and then failed silently wherever it was pasted. `wasGenerated` is cleared when the user types over a generated value, so an answer is only marked isSecret when it genuinely came from the generator - which is what makes the password analyser correct outside English. 12 reducer tests. This one class runs under Robolectric, unlike every other unit test in the module: SavedStateHandle.toRoute builds an android.os.Bundle to decode the typed route argument, which a plain JVM test cannot do. The alternative was to read the argument by string name in production and give up the type safety the previous commit introduced, so the test carries the weight instead. Robolectric is pointed at a stock Application, because the real one loads the SQLCipher native library in onCreate. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 8 + .../passkey/domain/model/PasswordPolicy.kt | 44 ++ .../screens/detail_screen/DetailContract.kt | 69 +++ .../screens/detail_screen/DetailEvents.kt | 32 -- .../screens/detail_screen/DetailPage.kt | 101 ++--- .../screens/detail_screen/DetailViewModel.kt | 392 ++++++++---------- .../detail_screen/components/DetailsItem.kt | 14 +- .../components/PasswordSettingsSheet.kt | 36 +- .../bhardwaj/passkey/DetailViewModelTest.kt | 270 ++++++++++++ gradle/libs.versions.toml | 2 + 10 files changed, 631 insertions(+), 337 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/model/PasswordPolicy.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8c4485..7740460 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,11 @@ android { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) } } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } buildFeatures { compose = true buildConfig = true @@ -186,6 +191,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.turbine) + // Only DetailViewModelTest needs this: SavedStateHandle.toRoute builds an android.os.Bundle + // internally, which a plain JVM test cannot. Every other unit test runs without it. + testImplementation(libs.robolectric) androidTestImplementation(libs.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/PasswordPolicy.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/PasswordPolicy.kt new file mode 100644 index 0000000..ca78959 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/PasswordPolicy.kt @@ -0,0 +1,44 @@ +package com.bhardwaj.passkey.domain.model + +/** Which character classes a generated password may draw from. */ +enum class PasswordCharacterClass { UPPERCASE, LOWERCASE, DIGITS, SYMBOLS } + +/** + * Settings for the password generator. + * + * Replaces five loose `mutableStateOf` fields on the ViewModel plus a toggle event that + * dispatched on the magic strings "Upper", "Lower", "Number" and "Special". + */ +data class PasswordPolicy( + val length: Int = DEFAULT_LENGTH, + val classes: Set = DEFAULT_CLASSES +) { + val includeUppercase: Boolean get() = PasswordCharacterClass.UPPERCASE in classes + val includeLowercase: Boolean get() = PasswordCharacterClass.LOWERCASE in classes + val includeDigits: Boolean get() = PasswordCharacterClass.DIGITS in classes + val includeSymbols: Boolean get() = PasswordCharacterClass.SYMBOLS in classes + + /** + * Toggling the last remaining class falls back to lowercase rather than leaving an empty + * pool. The generator has the same guard, but keeping the state itself always valid means + * the UI can never show every switch off. + */ + fun toggle(characterClass: PasswordCharacterClass, enabled: Boolean): PasswordPolicy { + val updated = if (enabled) classes + characterClass else classes - characterClass + return copy( + classes = updated.ifEmpty { setOf(PasswordCharacterClass.LOWERCASE) } + ) + } + + companion object { + const val MIN_LENGTH = 4 + const val MAX_LENGTH = 32 + const val DEFAULT_LENGTH = 12 + + val DEFAULT_CLASSES = setOf( + PasswordCharacterClass.UPPERCASE, + PasswordCharacterClass.LOWERCASE, + PasswordCharacterClass.DIGITS + ) + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt new file mode 100644 index 0000000..bab67b2 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt @@ -0,0 +1,69 @@ +package com.bhardwaj.passkey.presentation.screens.detail_screen + +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.PasswordCharacterClass +import com.bhardwaj.passkey.domain.model.PasswordPolicy +import com.bhardwaj.passkey.utils.UiText + +data class DetailState( + val query: String = "", + val items: List = emptyList(), + val isLoading: Boolean = true, + /** Non-null means the add/edit sheet is open. */ + val editor: Editor? = null, + /** Non-null means the delete confirmation is showing; the row is hidden but not deleted. */ + val pendingDelete: Detail? = null, + val policy: PasswordPolicy = PasswordPolicy(), + val isPolicySheetOpen: Boolean = false +) { + data class Editor( + val question: String = "", + val answer: String = "", + val editingId: Long? = null, + /** + * Whether the current answer came out of the generator. A generated value is + * unambiguously a secret, so the analyser never has to guess for it. + */ + val wasGenerated: Boolean = false + ) { + val isEdit: Boolean get() = editingId != null + } + + val showEmptyState: Boolean get() = !isLoading && items.isEmpty() && query.isBlank() +} + +sealed interface DetailIntent { + data class QueryChanged(val query: String) : DetailIntent + + data object AddClicked : DetailIntent + data class EditClicked(val detail: Detail) : DetailIntent + data class QuestionChanged(val question: String) : DetailIntent + data class AnswerChanged(val answer: String) : DetailIntent + data object SaveClicked : DetailIntent + data object EditorDismissed : DetailIntent + + data class SwipedToDelete(val detail: Detail) : DetailIntent + data object DeleteConfirmed : DetailIntent + data object DeleteCancelled : DetailIntent + + data class Moved(val fromIndex: Int, val toIndex: Int) : DetailIntent + data class LongPressed(val value: String) : DetailIntent + data object BackClicked : DetailIntent + + // Generator + data object PolicyClicked : DetailIntent + data object PolicyDismissed : DetailIntent + data class LengthChanged(val length: Int) : DetailIntent + /** Typed, unlike the old event which dispatched on a raw String. */ + data class CharacterClassToggled( + val characterClass: PasswordCharacterClass, + val enabled: Boolean + ) : DetailIntent + data object GenerateClicked : DetailIntent +} + +sealed interface DetailEffect { + data class ShowSnackbar(val message: UiText) : DetailEffect + data object PopBackStack : DetailEffect + data class CopyToClipboard(val value: String, val isSensitive: Boolean) : DetailEffect +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt deleted file mode 100644 index 3ac2b1a..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailEvents.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.bhardwaj.passkey.presentation.screens.detail_screen - -import androidx.compose.foundation.lazy.LazyListItemInfo -import com.bhardwaj.passkey.domain.model.Detail - -sealed interface DetailEvents { - // Dialogs. - data class OnTitleChange(val newTitle: String) : DetailEvents - data class OnDescriptionChange(val newDescription: String) : DetailEvents - data object OnSaveClick : DetailEvents - data object OnCancelClick : DetailEvents - data object OnDeleteClick : DetailEvents - data object OnDismissBottomSheet : DetailEvents - data object OnDismissAlertDialog : DetailEvents - - // Single Item. - data class OnLongPress(val detailsDescription: String) : DetailEvents - data class OnReorderDetails(val from: LazyListItemInfo, val to: LazyListItemInfo) : DetailEvents - data class OnChangeClick(val details: Detail) : DetailEvents - data class OnSwipedLeft(val details: Detail) : DetailEvents - - // Floating Action Button - data object OnAddDetailClick : DetailEvents - - // Search - data class OnSearchTextUpdate(val newText: String) : DetailEvents - data object OnGeneratePasswordClick : DetailEvents - data object OnPasswordSettingsClick : DetailEvents - data object OnDismissPasswordSettings : DetailEvents - data class OnPasswordLengthChange(val length: Float) : DetailEvents - data class OnTogglePasswordOption(val option: String, val value: Boolean) : DetailEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index d018e3a..00186b4 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailViewModel import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PasskeySearchBar @@ -72,41 +71,35 @@ fun DetailScreen( onPopBackStack: () -> Unit, viewModel: DetailViewModel = hiltViewModel() ) { - val detailTitle by viewModel.detailTitle.collectAsStateWithLifecycle() - val detailResponse by viewModel.detailResponse.collectAsStateWithLifecycle() - val isEditingSheet by viewModel.isEditingSheet.collectAsStateWithLifecycle() - // Resolved here rather than in the ViewModel so it follows the app locale. - val bottomSheetHeading = - stringResource(if (isEditingSheet) R.string.edit else R.string.add) - val searchText by viewModel.searchText.collectAsStateWithLifecycle() - val details by viewModel.details.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + // Hoisted: a bound method reference allocates a new object per recomposition, so every + // visible row recomposed on each keystroke. + val onIntent = remember(viewModel) { viewModel::onIntent } val snackBarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val view = LocalView.current val lazyListState = rememberLazyListState() val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to -> - viewModel.onEvent(DetailEvents.OnReorderDetails(from, to)) + onIntent(DetailIntent.Moved(from.index, to.index)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK) } } - val isPasswordSettingsOpen = viewModel.isPasswordSettingsOpen val context = LocalContext.current - ObserveAsEvents(viewModel.uiEvents) { event -> + ObserveAsEvents(viewModel.effects) { event -> when (event) { - is UiEvents.PopBackStack -> onPopBackStack() - is UiEvents.ShowSnackBar -> { + is DetailEffect.PopBackStack -> onPopBackStack() + is DetailEffect.ShowSnackbar -> { scope.launch { snackBarHostState.showSnackbar( - message = event.message.asString(context), - actionLabel = event.action?.asString(context) + message = event.message.asString(context) ) } } - is UiEvents.CopyToClipboard -> { + is DetailEffect.CopyToClipboard -> { SecureClipboard.copy( context = context, text = event.value, @@ -132,7 +125,7 @@ fun DetailScreen( floatingActionButton = { FloatingActionButton( onClick = { - viewModel.onEvent(DetailEvents.OnAddDetailClick) + onIntent(DetailIntent.AddClicked) }, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, @@ -165,20 +158,20 @@ fun DetailScreen( color = MaterialTheme.colorScheme.secondary ) - if (searchText.isNotBlank() || details.isNotEmpty()) { + if (state.query.isNotBlank() || state.items.isNotEmpty()) { PasskeySearchBar( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 16.dp), - query = searchText, - onQueryChange = { viewModel.onEvent(DetailEvents.OnSearchTextUpdate(newText = it)) }, + query = state.query, + onQueryChange = { onIntent(DetailIntent.QueryChanged(it)) }, onTrailingIconClick = { - viewModel.onEvent(DetailEvents.OnSearchTextUpdate(newText = "")) + onIntent(DetailIntent.QueryChanged("")) } ) } - if (details.isEmpty()) { + if (state.showEmptyState) { Image( modifier = Modifier .weight(1F) @@ -192,11 +185,11 @@ fun DetailScreen( state = lazyListState, ) { items( - items = details, + items = state.items, key = { item -> item.id } ) { detail -> - if (searchText.isNotBlank()) { - DetailsItem(details = detail, onEvent = viewModel::onEvent) + if (state.query.isNotBlank()) { + DetailsItem(detail = detail, onIntent = onIntent) } else { val state = rememberSwipeToDismissBoxState( initialValue = SwipeToDismissBoxValue.Settled, @@ -205,7 +198,7 @@ fun DetailScreen( LaunchedEffect(state.currentValue) { if (state.currentValue == SwipeToDismissBoxValue.EndToStart) { - viewModel.onEvent(DetailEvents.OnSwipedLeft(detail)) + onIntent(DetailIntent.SwipedToDelete(detail)) state.snapTo(SwipeToDismissBoxValue.Settled) } } @@ -236,8 +229,8 @@ fun DetailScreen( content = { DetailsItem( scope = this@ReorderableItem, - details = detail, - onEvent = viewModel::onEvent + detail = detail, + onIntent = onIntent ) }, enableDismissFromEndToStart = true, @@ -249,64 +242,58 @@ fun DetailScreen( } } } - if (viewModel.isSheetOpen) { + state.editor?.let { editor -> DetailsBottomSheet( modifier = Modifier, - bottomSheetHeading = bottomSheetHeading, - detailTitle = detailTitle, - detailResponse = detailResponse, + bottomSheetHeading = stringResource( + if (editor.isEdit) R.string.edit else R.string.add + ), + detailTitle = editor.question, + detailResponse = editor.answer, onTitleChange = { - viewModel.onEvent(DetailEvents.OnTitleChange(it)) + onIntent(DetailIntent.QuestionChanged(it)) }, onDescriptionChange = { - viewModel.onEvent(DetailEvents.OnDescriptionChange(it)) + onIntent(DetailIntent.AnswerChanged(it)) }, onDismiss = { - viewModel.onEvent(DetailEvents.OnDismissBottomSheet) + onIntent(DetailIntent.EditorDismissed) }, onSave = { - viewModel.onEvent(DetailEvents.OnSaveClick) + onIntent(DetailIntent.SaveClicked) }, onGeneratePasswordClicked = { - viewModel.onEvent(DetailEvents.OnGeneratePasswordClick) + onIntent(DetailIntent.GenerateClicked) }, onPasswordSettingsClicked = { - viewModel.onEvent(DetailEvents.OnPasswordSettingsClick) + onIntent(DetailIntent.PolicyClicked) } ) } } - if (isPasswordSettingsOpen) { + if (state.isPolicySheetOpen) { PasswordSettingsSheet( - length = viewModel.passwordLength, - includeUpper = viewModel.includeUpper, - includeLower = viewModel.includeLower, - includeNumbers = viewModel.includeNumbers, - includeSpecial = viewModel.includeSpecial, - onPasswordLengthChange = { - viewModel.onEvent(DetailEvents.OnPasswordLengthChange(it)) - }, - onDismissPasswordSettings = { - viewModel.onEvent(DetailEvents.OnDismissPasswordSettings) - }, - onTogglePasswordOption = { type, checked -> - viewModel.onEvent(DetailEvents.OnTogglePasswordOption(type, checked)) + policy = state.policy, + onPasswordLengthChange = { onIntent(DetailIntent.LengthChanged(it)) }, + onDismissPasswordSettings = { onIntent(DetailIntent.PolicyDismissed) }, + onToggleCharacterClass = { characterClass, enabled -> + onIntent(DetailIntent.CharacterClassToggled(characterClass, enabled)) } ) } - if (viewModel.isAlertOpen) { + if (state.pendingDelete != null) { AlertDialog( properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), containerColor = MaterialTheme.colorScheme.background, onDismissRequest = { - viewModel.onEvent(DetailEvents.OnDismissAlertDialog) + onIntent(DetailIntent.DeleteCancelled) }, title = { Text(text = stringResource(id = R.string.confirm_delete_title)) }, text = { Text(text = stringResource(id = R.string.confirm_delete_description_question)) }, confirmButton = { PassKeyButton( onClick = { - viewModel.onEvent(DetailEvents.OnDeleteClick) + onIntent(DetailIntent.DeleteConfirmed) }, text = stringResource(id = R.string.delete), buttonType = ButtonType.DEFAULT @@ -315,7 +302,7 @@ fun DetailScreen( dismissButton = { PassKeyButton( onClick = { - viewModel.onEvent(DetailEvents.OnCancelClick) + onIntent(DetailIntent.DeleteCancelled) }, text = stringResource(id = R.string.cancel), buttonType = ButtonType.OUTLINED diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt index c2b9d03..bf7619b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt @@ -1,28 +1,21 @@ package com.bhardwaj.passkey.presentation.screens.detail_screen -import androidx.navigation.toRoute -import com.bhardwaj.passkey.presentation.navigation.NavRoute -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import com.bhardwaj.passkey.R import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.PasswordPolicy import com.bhardwaj.passkey.domain.repository.PasskeyRepository -import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents -import com.bhardwaj.passkey.utils.Constants.Companion.BOTTOM_SHEET_HEADING +import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.utils.PasswordGenerator -import com.bhardwaj.passkey.utils.UiEvents import com.bhardwaj.passkey.utils.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -32,253 +25,208 @@ import javax.inject.Inject @HiltViewModel class DetailViewModel @Inject constructor( private val repository: PasskeyRepository, - private val savedStateHandle: SavedStateHandle + savedStateHandle: SavedStateHandle ) : ViewModel() { - // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded - // screen has no active collector, and a rendezvous channel would suspend the coroutine - // that emitted the effect until the user came back. - private val _uiEvents = Channel(Channel.BUFFERED) - val uiEvents = _uiEvents.receiveAsFlow() - - // Deliberately NOT in SavedStateHandle. That is serialized into the saved-instance-state - // bundle, which the system writes to disk under /data/system_ce// for task - // persistence - so the in-progress secret, including a freshly generated password, would be - // stored in cleartext outside the encrypted database. Losing a half-typed entry to process - // death is the correct trade here. - private val _detailTitle = MutableStateFlow("") - val detailTitle = _detailTitle.asStateFlow() - - /** Set when the current draft value came from the password generator. */ - private var wasGenerated = false - - private val _detailResponse = MutableStateFlow("") - val detailResponse = _detailResponse.asStateFlow() - /** true = editing an existing row, false = adding. Resolved to text by the UI. */ - val isEditingSheet = savedStateHandle.getStateFlow(BOTTOM_SHEET_HEADING, false) - // toRoute decodes the typed argument. The old form read a stringly-named key and fell back - // to -1, which is why a "something went wrong" branch existed below for an id that could - // never legitimately arrive. - val previewId: Long = savedStateHandle.toRoute().previewId - private val _searchText = MutableStateFlow("") - val searchText = _searchText.asStateFlow() + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() - val details: StateFlow> = repository.getDetailsByPreviewId(previewId = previewId) - .combine(searchText) { details, text -> - if (text.isBlank()) { - details - } else { - details.filter { - it.question.contains( - text, - ignoreCase = true - ) or it.answer.contains(text, ignoreCase = true) + /** Decoded from the typed route, so it cannot be a -1 sentinel. */ + val previewId: Long = savedStateHandle.toRoute().previewId + + private val query = MutableStateFlow("") + + /** + * Editor, pending delete and generator settings. + * + * Deliberately not in SavedStateHandle: it holds the in-progress answer, and SavedStateHandle + * is serialized into the saved-instance-state bundle, which the system persists to disk. + * Losing a half-typed entry to process death is the right side of that trade. + */ + private val transient = MutableStateFlow(TransientState()) + + private data class TransientState( + val editor: DetailState.Editor? = null, + val pendingDelete: Detail? = null, + val policy: PasswordPolicy = PasswordPolicy(), + val isPolicySheetOpen: Boolean = false + ) + + val state: StateFlow = combine( + query, + transient, + repository.getDetailsByPreviewId(previewId) + ) { currentQuery, ui, items -> + DetailState( + query = currentQuery, + items = items + .filter { + currentQuery.isBlank() || + it.question.contains(currentQuery, true) || + it.answer.contains(currentQuery, true) + } + .filterNot { it.id == ui.pendingDelete?.id }, + isLoading = false, + editor = ui.editor, + pendingDelete = ui.pendingDelete, + policy = ui.policy, + isPolicySheetOpen = ui.isPolicySheetOpen + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetailState()) + + fun onIntent(intent: DetailIntent) { + when (intent) { + is DetailIntent.QueryChanged -> query.value = intent.query + + DetailIntent.AddClicked -> { + query.value = "" + transient.update { it.copy(editor = DetailState.Editor()) } + } + + is DetailIntent.EditClicked -> { + query.value = "" + transient.update { + it.copy( + editor = DetailState.Editor( + question = intent.detail.question, + answer = intent.detail.answer, + editingId = intent.detail.id + ) + ) } - } - } - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - - var isSheetOpen by mutableStateOf(false) - private set - - var isAlertOpen by mutableStateOf(false) - private set - - var detail by mutableStateOf(null) - private set - - var isPasswordSettingsOpen by mutableStateOf(false) - private set - - var passwordLength by mutableFloatStateOf(12f) - private set - var includeUpper by mutableStateOf(true) - private set - var includeLower by mutableStateOf(true) - private set - var includeNumbers by mutableStateOf(true) - private set - var includeSpecial by mutableStateOf(false) - private set - - private var deletedDetail: Detail? = null - - fun onEvent(event: DetailEvents) { - when (event) { - DetailEvents.OnAddDetailClick -> { - savedStateHandle[BOTTOM_SHEET_HEADING] = false - isSheetOpen = true - _searchText.value = "" } - DetailEvents.OnDismissBottomSheet -> { - _detailTitle.value = "" - _detailResponse.value = "" - isSheetOpen = false - } + is DetailIntent.QuestionChanged -> + transient.update { it.copy(editor = it.editor?.copy(question = intent.question)) } - is DetailEvents.OnChangeClick -> { - viewModelScope.launch { - _searchText.value = "" - // The row is already a fully-formed domain object; re-reading it by id was - // a needless round trip. - isSheetOpen = true - savedStateHandle[BOTTOM_SHEET_HEADING] = true - _detailTitle.value = event.details.question - _detailResponse.value = event.details.answer - detail = event.details + is DetailIntent.AnswerChanged -> + transient.update { + // Typing over a generated value means it is no longer generator output. + it.copy(editor = it.editor?.copy(answer = intent.answer, wasGenerated = false)) } - } - is DetailEvents.OnTitleChange -> { - _detailTitle.value = event.newTitle - } + DetailIntent.EditorDismissed -> transient.update { it.copy(editor = null) } - is DetailEvents.OnDescriptionChange -> { - _detailResponse.value = event.newDescription - } - - DetailEvents.OnSaveClick -> { - viewModelScope.launch { - if (detailTitle.value.isBlank() or detailResponse.value.isBlank()) { - isSheetOpen = false - _detailTitle.value = "" - _detailResponse.value = "" - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.enter_valid_title_n_response) - ) - ) - return@launch - } - val question = detailTitle.value.trim() - val answer = detailResponse.value.trim() - - detail?.let { - repository.updateDetail( - it.copy( - // Trimmed on edit too. A trailing space in a stored password - // fails silently wherever it is pasted. - question = question, - answer = answer, - isSecret = it.isSecret || wasGenerated - ) - ) - } ?: repository.createDetail( - previewId = previewId, - question = question, - answer = answer, - // A value that came out of the generator is unambiguously a secret, so - // the analyser never has to guess for it. - isSecret = wasGenerated - ) + DetailIntent.SaveClicked -> save() - detail = null - _detailTitle.value = "" - _detailResponse.value = "" - isSheetOpen = false - } - } + is DetailIntent.SwipedToDelete -> + // Nothing written yet; the old code deleted here and re-inserted on cancel, so a + // process death while the confirmation was open lost the entry. + transient.update { it.copy(pendingDelete = intent.detail) } - is DetailEvents.OnLongPress -> { - sendUiEvents(UiEvents.CopyToClipboard(value = event.detailsDescription, isSensitive = true)) - } + DetailIntent.DeleteCancelled -> transient.update { it.copy(pendingDelete = null) } - is DetailEvents.OnSwipedLeft -> { + DetailIntent.DeleteConfirmed -> { + val target = transient.value.pendingDelete ?: return viewModelScope.launch { - isAlertOpen = true - repository.deleteDetail(event.details) - deletedDetail = event.details + repository.deleteDetail(target) + transient.update { it.copy(pendingDelete = null) } } } - DetailEvents.OnDismissAlertDialog -> { - viewModelScope.launch { - isAlertOpen = false - deletedDetail?.let { repository.updateDetail(it) } - deletedDetail = null - } - } + is DetailIntent.Moved -> move(intent.fromIndex, intent.toIndex) - DetailEvents.OnCancelClick -> { - viewModelScope.launch { - isAlertOpen = false - deletedDetail?.let { repository.updateDetail(it) } - deletedDetail = null - } - } + is DetailIntent.LongPressed -> + // Answers are secrets: marked sensitive so Android 13+ redacts the paste + // preview, and auto-cleared from the clipboard. + emit(DetailEffect.CopyToClipboard(intent.value, isSensitive = true)) - is DetailEvents.OnDeleteClick -> { - isAlertOpen = false - deletedDetail = null - } + DetailIntent.BackClicked -> emit(DetailEffect.PopBackStack) - is DetailEvents.OnSearchTextUpdate -> { - _searchText.value = event.newText - } + DetailIntent.PolicyClicked -> transient.update { it.copy(isPolicySheetOpen = true) } - is DetailEvents.OnReorderDetails -> { - viewModelScope.launch { - val newList = details.value.toMutableList().apply { - add(event.to.index, removeAt(event.from.index)) - } - - val updatedList = newList.mapIndexed { index, detail -> - detail.copy(sequence = index.toLong()) - } - - repository.runInTransaction { - updatedList.forEach { detail -> - repository.updateDetailSequence( - detailId = detail.id, - sequence = detail.sequence - ) - } - } - } - } + DetailIntent.PolicyDismissed -> transient.update { it.copy(isPolicySheetOpen = false) } - DetailEvents.OnGeneratePasswordClick -> { - wasGenerated = true - val newPassword = PasswordGenerator.generate( - length = passwordLength.toInt(), - includeUpper = includeUpper, - includeLower = includeLower, - includeNumbers = includeNumbers, - includeSpecial = includeSpecial + is DetailIntent.LengthChanged -> transient.update { + it.copy( + policy = it.policy.copy( + length = intent.length + .coerceIn(PasswordPolicy.MIN_LENGTH, PasswordPolicy.MAX_LENGTH) + ) ) - _detailResponse.value = newPassword } - DetailEvents.OnPasswordSettingsClick -> { - isPasswordSettingsOpen = true + is DetailIntent.CharacterClassToggled -> transient.update { + it.copy(policy = it.policy.toggle(intent.characterClass, intent.enabled)) } - DetailEvents.OnDismissPasswordSettings -> { - isPasswordSettingsOpen = false + DetailIntent.GenerateClicked -> transient.update { + val policy = it.policy + val generated = PasswordGenerator.generate( + length = policy.length, + includeUpper = policy.includeUppercase, + includeLower = policy.includeLowercase, + includeNumbers = policy.includeDigits, + includeSpecial = policy.includeSymbols + ) + it.copy( + editor = (it.editor ?: DetailState.Editor()) + .copy(answer = generated, wasGenerated = true) + ) } + } + } - is DetailEvents.OnPasswordLengthChange -> { - passwordLength = event.length - } + private fun save() { + val editor = transient.value.editor ?: return + val question = editor.question.trim() + // Trimmed on both paths. Previously only the create path trimmed, so a trailing space + // crept into stored passwords on edit and failed silently wherever it was pasted. + val answer = editor.answer.trim() + + if (question.isBlank() || answer.isBlank()) { + transient.update { it.copy(editor = null) } + emit( + DetailEffect.ShowSnackbar( + UiText.StringResource(R.string.enter_valid_title_n_response) + ) + ) + return + } - is DetailEvents.OnTogglePasswordOption -> { - when (event.option) { - "Upper" -> includeUpper = event.value - "Lower" -> includeLower = event.value - "Number" -> includeNumbers = event.value - "Special" -> includeSpecial = event.value - } - if (!includeUpper && !includeLower && !includeNumbers && !includeSpecial) { - includeLower = true + viewModelScope.launch { + val editingId = editor.editingId + if (editingId != null) { + state.value.items.firstOrNull { it.id == editingId }?.let { existing -> + repository.updateDetail( + existing.copy( + question = question, + answer = answer, + isSecret = existing.isSecret || editor.wasGenerated + ) + ) } + } else { + repository.createDetail( + previewId = previewId, + question = question, + answer = answer, + isSecret = editor.wasGenerated + ) } + transient.update { it.copy(editor = null) } } } - private fun sendUiEvents(events: UiEvents) { + private fun move(fromIndex: Int, toIndex: Int) { + val current = state.value.items + if (fromIndex !in current.indices || toIndex !in current.indices) return + val reordered = current.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } viewModelScope.launch { - _uiEvents.send(events) + // One transaction, not N separate writes. + repository.runInTransaction { + reordered.forEachIndexed { index, detail -> + repository.updateDetailSequence(detail.id, index.toLong()) + } + } } } -} \ No newline at end of file + + private fun emit(effect: DetailEffect) { + viewModelScope.launch { _effects.send(effect) } + } +} + +private inline fun MutableStateFlow.update(transform: (T) -> T) { + value = transform(value) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt index f57c380..9270749 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R import com.bhardwaj.passkey.domain.model.Detail -import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEvents +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailIntent import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins import sh.calvin.reorderable.ReorderableCollectionItemScope @@ -43,8 +43,8 @@ import sh.calvin.reorderable.ReorderableCollectionItemScope fun DetailsItem( modifier: Modifier = Modifier, scope: ReorderableCollectionItemScope? = null, - details: Detail, - onEvent: (DetailEvents) -> Unit, + detail: Detail, + onIntent: (DetailIntent) -> Unit, ) { val view = LocalView.current Box( @@ -95,12 +95,12 @@ fun DetailsItem( .combinedClickable( onClick = {}, onLongClick = { - onEvent(DetailEvents.OnLongPress(details.answer)) + onIntent(DetailIntent.LongPressed(detail.answer)) }, indication = null, interactionSource = remember { MutableInteractionSource() } ), - text = details.answer, + text = detail.answer, fontFamily = Poppins, fontWeight = FontWeight.Normal, fontSize = 18.sp, @@ -111,7 +111,7 @@ fun DetailsItem( Icon( modifier = Modifier.clickable( onClick = { - onEvent(DetailEvents.OnChangeClick(details)) + onIntent(DetailIntent.EditClicked(detail)) }, indication = null, interactionSource = remember { MutableInteractionSource() } @@ -127,7 +127,7 @@ fun DetailsItem( .padding(start = 16.dp, end = 16.dp) .offset(y = (-6).dp) .background(MaterialTheme.colorScheme.background), - text = details.question, + text = detail.question, fontFamily = BebasNeue, fontWeight = FontWeight.Normal, fontSize = 14.sp, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt index 9fa3c4a..798bf50 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt @@ -1,5 +1,7 @@ package com.bhardwaj.passkey.presentation.screens.detail_screen.components +import com.bhardwaj.passkey.domain.model.PasswordCharacterClass +import com.bhardwaj.passkey.domain.model.PasswordPolicy import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,14 +36,10 @@ import com.bhardwaj.passkey.presentation.theme.Poppins @OptIn(ExperimentalMaterial3Api::class) @Composable fun PasswordSettingsSheet( - length: Float, - includeUpper: Boolean, - includeLower: Boolean, - includeNumbers: Boolean, - includeSpecial: Boolean, + policy: PasswordPolicy, onDismissPasswordSettings: () -> Unit, - onPasswordLengthChange: (length: Float) -> Unit, - onTogglePasswordOption: (type: String, checked: Boolean) -> Unit + onPasswordLengthChange: (length: Int) -> Unit, + onToggleCharacterClass: (PasswordCharacterClass, Boolean) -> Unit ) { val sheetState = rememberModalBottomSheetState() @@ -70,15 +68,15 @@ fun PasswordSettingsSheet( ) Text( - text = stringResource(R.string.password_length, length.toInt()), + text = stringResource(R.string.password_length, policy.length), fontFamily = Poppins, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground ) Slider( - value = length, - onValueChange = { onPasswordLengthChange(it) }, - valueRange = 4f..32f, + value = policy.length.toFloat(), + onValueChange = { onPasswordLengthChange(it.toInt()) }, + valueRange = PasswordPolicy.MIN_LENGTH.toFloat()..PasswordPolicy.MAX_LENGTH.toFloat(), steps = 27, colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, @@ -90,20 +88,20 @@ fun PasswordSettingsSheet( PasswordToggleRow( "A-Z", - includeUpper - ) { onTogglePasswordOption("Upper", it) } + policy.includeUppercase + ) { onToggleCharacterClass(PasswordCharacterClass.UPPERCASE, it) } PasswordToggleRow( "a-z", - includeLower - ) { onTogglePasswordOption("Lower", it) } + policy.includeLowercase + ) { onToggleCharacterClass(PasswordCharacterClass.LOWERCASE, it) } PasswordToggleRow( "0-9", - includeNumbers - ) { onTogglePasswordOption("Number", it) } + policy.includeDigits + ) { onToggleCharacterClass(PasswordCharacterClass.DIGITS, it) } PasswordToggleRow( "!@#", - includeSpecial - ) { onTogglePasswordOption("Special", it) } + policy.includeSymbols + ) { onToggleCharacterClass(PasswordCharacterClass.SYMBOLS, it) } } } } diff --git a/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt b/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt new file mode 100644 index 0000000..b768614 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt @@ -0,0 +1,270 @@ +package com.bhardwaj.passkey + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.PasswordCharacterClass +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEffect +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailIntent +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailViewModel +import com.bhardwaj.passkey.utils.UiText +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Runs under Robolectric, unlike every other unit test in this module. + * + * SavedStateHandle.toRoute builds an android.os.Bundle internally to decode the typed route + * argument, which plain JVM tests cannot do. Making the production code read the argument by + * string name instead would have suited the test at the cost of the type safety this release + * introduced, so the test carries the weight rather than the ViewModel. + */ +@RunWith(RobolectricTestRunner::class) +// A stock Application, not PasskeyApplication: the real one loads the SQLCipher native +// library in onCreate, which does not exist on the JVM. This test needs Android types, not +// the app's runtime. +@Config(sdk = [34], application = android.app.Application::class) +@OptIn(ExperimentalCoroutinesApi::class) +class DetailViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var repository: FakePasskeyRepository + + private companion object { + const val PREVIEW_ID = 42L + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = FakePasskeyRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + /** + * The ViewModel decodes its argument with `toRoute`, which reads the route's serialized + * arguments out of the handle under the destination's own key. + */ + private fun viewModel() = DetailViewModel( + repository, + SavedStateHandle(mapOf("previewId" to PREVIEW_ID)) + ) + + private suspend fun seedDetail(question: String, answer: String): Long = + repository.createDetail(previewId = PREVIEW_ID, question = question, answer = answer) + + @Test + fun `swiping to delete hides the row but writes nothing`() = runTest(dispatcher) { + seedDetail("Password", "secret") + val writesAfterSeed = repository.writeCount + val vm = viewModel() + + vm.state.test { + awaitItem() + val row = repository.currentDetails().single() + vm.onIntent(DetailIntent.SwipedToDelete(row)) + val state = expectMostRecentItem() + + assertThat(state.pendingDelete?.id).isEqualTo(row.id) + assertThat(state.items).isEmpty() + cancelAndIgnoreRemainingEvents() + } + + assertThat(repository.writeCount).isEqualTo(writesAfterSeed) + assertThat(repository.currentDetails()).hasSize(1) + } + + @Test + fun `cancelling a delete restores the row`() = runTest(dispatcher) { + seedDetail("Password", "secret") + val vm = viewModel() + + vm.state.test { + awaitItem() + val row = repository.currentDetails().single() + vm.onIntent(DetailIntent.SwipedToDelete(row)) + vm.onIntent(DetailIntent.DeleteCancelled) + assertThat(expectMostRecentItem().items).hasSize(1) + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.currentDetails()).hasSize(1) + } + + @Test + fun `both create and edit trim the answer`() = runTest(dispatcher) { + // Only the create path trimmed before, so a trailing space crept into stored passwords + // on edit and then failed silently wherever it was pasted. + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddClicked) + vm.onIntent(DetailIntent.QuestionChanged(" Password ")) + vm.onIntent(DetailIntent.AnswerChanged(" s3cret ")) + vm.onIntent(DetailIntent.SaveClicked) + expectMostRecentItem() + + val created = repository.currentDetails().single() + assertThat(created.question).isEqualTo("Password") + assertThat(created.answer).isEqualTo("s3cret") + + vm.onIntent(DetailIntent.EditClicked(created)) + vm.onIntent(DetailIntent.AnswerChanged(" changed ")) + vm.onIntent(DetailIntent.SaveClicked) + expectMostRecentItem() + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.currentDetails().single().answer).isEqualTo("changed") + } + + @Test + fun `a blank question or answer is rejected without writing`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(DetailIntent.AddClicked) + vm.onIntent(DetailIntent.QuestionChanged("Password")) + vm.onIntent(DetailIntent.AnswerChanged(" ")) + vm.onIntent(DetailIntent.SaveClicked) + + assertThat(awaitItem()).isEqualTo( + DetailEffect.ShowSnackbar( + UiText.StringResource(R.string.enter_valid_title_n_response) + ) + ) + } + assertThat(repository.currentDetails()).isEmpty() + } + + @Test + fun `a generated answer is stored as a secret`() = runTest(dispatcher) { + // isSecret is what makes the analyser correct outside English, so the generator marking + // its own output matters. + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddClicked) + vm.onIntent(DetailIntent.QuestionChanged("Password")) + vm.onIntent(DetailIntent.GenerateClicked) + vm.onIntent(DetailIntent.SaveClicked) + expectMostRecentItem() + cancelAndIgnoreRemainingEvents() + } + val created = repository.currentDetails().single() + assertThat(created.isSecret).isTrue() + assertThat(created.answer).isNotEmpty() + } + + @Test + fun `typing over a generated answer clears the generated flag`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddClicked) + vm.onIntent(DetailIntent.QuestionChanged("Note")) + vm.onIntent(DetailIntent.GenerateClicked) + vm.onIntent(DetailIntent.AnswerChanged("typed by hand")) + vm.onIntent(DetailIntent.SaveClicked) + expectMostRecentItem() + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.currentDetails().single().isSecret).isFalse() + } + + @Test + fun `turning off the last character class falls back to lowercase`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.PolicyClicked) + PasswordCharacterClass.entries.forEach { + vm.onIntent(DetailIntent.CharacterClassToggled(it, enabled = false)) + } + val policy = expectMostRecentItem().policy + // An empty pool would be unrepresentable state; the UI can never show all switches off. + assertThat(policy.classes).containsExactly(PasswordCharacterClass.LOWERCASE) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `length is clamped to the supported range`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.LengthChanged(999)) + assertThat(expectMostRecentItem().policy.length).isEqualTo(32) + vm.onIntent(DetailIntent.LengthChanged(1)) + assertThat(expectMostRecentItem().policy.length).isEqualTo(4) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `long pressing an answer copies it as sensitive`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(DetailIntent.LongPressed("s3cret")) + // Unlike an entry heading, an answer is a secret. + assertThat(awaitItem()) + .isEqualTo(DetailEffect.CopyToClipboard("s3cret", isSensitive = true)) + } + } + + @Test + fun `query matches both question and answer`() = runTest(dispatcher) { + seedDetail("Username", "alice") + seedDetail("Password", "s3cret") + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.QueryChanged("alice")) + assertThat(expectMostRecentItem().items.map { it.question }) + .containsExactly("Username") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `only details for this entry are shown`() = runTest(dispatcher) { + seedDetail("Mine", "a") + repository.createDetail(previewId = 999, question = "Someone else", answer = "b") + val vm = viewModel() + + vm.state.test { + // Seeded before the ViewModel existed, so the first emission already carries them; + // there is no later emission to wait for. + assertThat(awaitItem().items.map { it.question }).containsExactly("Mine") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `reordering renumbers sequences in one transaction`() = runTest(dispatcher) { + seedDetail("A", "1"); seedDetail("B", "2"); seedDetail("C", "3") + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.Moved(fromIndex = 0, toIndex = 2)) + cancelAndIgnoreRemainingEvents() + } + + val ordered: List = repository.currentDetails().sortedBy { it.sequence } + assertThat(ordered.map { it.question }).containsExactly("B", "C", "A").inOrder() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11578b0..74c43b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ material_icons = "1.7.8" truth = "1.4.5" mockk = "1.14.11" turbine = "1.2.1" +robolectric = "4.16.1" kotlinx_serialization = "1.11.0" argon2kt = "1.6.0" @@ -149,5 +150,6 @@ google_truth = { module = "com.google.truth:truth", version.ref = "truth" } kotlinx_coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines_core" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" } compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose_ui_test" } From ef6821c2cbd7fbc228aad391bb40deba3f4d434e Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:02:29 +0530 Subject: [PATCH 18/25] Convert the settings screen to single-state MVI Extracts the non-UI work first: AppLocaleManager (the API 33+ LocaleManager fork), SecretKeywordProvider (the two-locale resource lookup) and AppInfo, which reads VERSION_NAME from BuildConfig instead of calling getPackageInfo on the main thread at ViewModel construction. The screen's two sheets were an isSheetOpen boolean plus a separate bottomSheetOpenedBy that could disagree, and closing the language picker revealed whichever legal text had been opened last. They are now one nullable Sheet with Language and Info(topic) cases, so that state is unrepresentable. The tri-state recoveryChangeStep: Boolean? becomes a named enum, and the analysis result carries its own visibility. Also here, because the same files were open: - Rating the app is an effect. It was started from the Application context with FLAG_ACTIVITY_NEW_TASK and no handling for a device without Play, which threw ActivityNotFoundException; the UI now starts it from the Activity and reports store_unavailable instead. - The recovery password held between the two dialog steps is zeroed when the flow is abandoned and in onCleared, not only on success. - Password analysis moves to an injected @DefaultDispatcher, which is what makes it assertable in a test. - Language.comingSoon was false for all 17 entries and nothing read it; deleted along with coming_soon in all 17 locales. - SettingsScreen's onPopBackStack was wired but no effect ever popped. Nine SettingsViewModel tests, 76 unit tests passing; clean assembleDebug, testDebugUnitTest and lintDebug green. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/bhardwaj/passkey/data/AppInfo.kt | 16 + .../data/analysis/SecretKeywordProvider.kt | 31 ++ .../passkey/data/locale/AppLocaleManager.kt | 38 ++ .../bhardwaj/passkey/di/DispatcherModule.kt | 27 ++ .../bhardwaj/passkey/domain/model/Language.kt | 3 +- .../presentation/navigation/NavGraph.kt | 5 +- .../settings_screen/SettingsContract.kt | 107 ++++++ .../screens/settings_screen/SettingsEvents.kt | 65 ---- .../screens/settings_screen/SettingsPage.kt | 206 +++++----- .../settings_screen/SettingsViewModel.kt | 356 ++++++++---------- .../components/LanguageBottomSheet.kt | 51 +-- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-bn/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-gu/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-mr/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-ta/strings.xml | 2 +- app/src/main/res/values-te/strings.xml | 2 +- app/src/main/res/values-zh/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../bhardwaj/passkey/SettingsViewModelTest.kt | 272 +++++++++++++ 29 files changed, 779 insertions(+), 432 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/AppInfo.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/analysis/SecretKeywordProvider.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/locale/AppLocaleManager.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/di/DispatcherModule.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/data/AppInfo.kt b/app/src/main/java/com/bhardwaj/passkey/data/AppInfo.kt new file mode 100644 index 0000000..b50c709 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/AppInfo.kt @@ -0,0 +1,16 @@ +package com.bhardwaj.passkey.data + +import com.bhardwaj.passkey.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Static facts about the running build. + * + * Read from BuildConfig rather than PackageManager: the previous code called getPackageInfo on + * the main thread during ViewModel construction to obtain a value that cannot change. + */ +@Singleton +class AppInfo @Inject constructor() { + val versionName: String = BuildConfig.VERSION_NAME +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/analysis/SecretKeywordProvider.kt b/app/src/main/java/com/bhardwaj/passkey/data/analysis/SecretKeywordProvider.kt new file mode 100644 index 0000000..be6ebae --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/analysis/SecretKeywordProvider.kt @@ -0,0 +1,31 @@ +package com.bhardwaj.passkey.data.analysis + +import android.content.Context +import android.content.res.Configuration +import com.bhardwaj.passkey.R +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Supplies the words that identify a field as holding a secret. + * + * Returns the union of the current locale's list and the English one. A vault may hold entries + * labelled before the user switched language, so matching only the current locale would silently + * stop classifying them. + */ +@Singleton +class SecretKeywordProvider @Inject constructor( + @param:ApplicationContext private val context: Context +) { + fun keywords(): Set { + val current = context.resources.getStringArray(R.array.secret_field_keywords).toSet() + val englishConfig = Configuration(context.resources.configuration).apply { + setLocale(Locale.ENGLISH) + } + val fallback = context.createConfigurationContext(englishConfig) + .resources.getStringArray(R.array.secret_field_keywords).toSet() + return current + fallback + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/locale/AppLocaleManager.kt b/app/src/main/java/com/bhardwaj/passkey/data/locale/AppLocaleManager.kt new file mode 100644 index 0000000..16e1446 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/locale/AppLocaleManager.kt @@ -0,0 +1,38 @@ +package com.bhardwaj.passkey.data.locale + +import android.app.LocaleManager +import android.content.Context +import android.os.Build +import android.os.LocaleList +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Applies and persists the per-app language. + * + * Extracted from SettingsViewModel, which was reaching for LocaleManager and AppCompatDelegate + * directly. The API fork matters: on API 33+ the platform LocaleManager updates the process + * configuration, while below that AppCompatDelegate applies the override per Activity only - + * which is why resolving strings against the Application context returned the previous language. + */ +@Singleton +class AppLocaleManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val preferences: PreferencesRepository +) { + suspend fun apply(languageTag: String) { + preferences.setSelectedLanguageTag(languageTag) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.getSystemService(LocaleManager::class.java)?.applicationLocales = + LocaleList.forLanguageTags(languageTag) + } else { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(languageTag) + ) + } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/di/DispatcherModule.kt b/app/src/main/java/com/bhardwaj/passkey/di/DispatcherModule.kt new file mode 100644 index 0000000..02e186c --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/di/DispatcherModule.kt @@ -0,0 +1,27 @@ +package com.bhardwaj.passkey.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +/** + * Marks the dispatcher for CPU-bound work, so callers inject it rather than reaching for + * `Dispatchers.Default` inline. Work handed off to a hardcoded dispatcher cannot be made + * deterministic in a test, which is what kept the password analysis untested. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object DispatcherModule { + + @Provides + @DefaultDispatcher + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt index 7b1ea6d..6ec9b49 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt @@ -3,6 +3,5 @@ package com.bhardwaj.passkey.domain.model data class Language( val languageId: String, val languageName: String, - val languageNameInEnglish: String, - val comingSoon: Boolean + val languageNameInEnglish: String ) \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt index b925439..ce607da 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt @@ -39,10 +39,7 @@ fun NavGraph(navController: NavHostController) { } composable { - SettingsScreen( - onPopBackStack = { navController.popBackStack() }, - onNavigate = { navController.navigate(it) } - ) + SettingsScreen(onNavigate = { navController.navigate(it) }) } } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt new file mode 100644 index 0000000..af176f6 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt @@ -0,0 +1,107 @@ +package com.bhardwaj.passkey.presentation.screens.settings_screen + +import android.net.Uri +import com.bhardwaj.passkey.data.backup.ImportMode +import com.bhardwaj.passkey.domain.model.AutoLockTimeout +import com.bhardwaj.passkey.domain.model.Language +import com.bhardwaj.passkey.presentation.navigation.NavRoute +import com.bhardwaj.passkey.utils.AlertBy +import com.bhardwaj.passkey.utils.PasswordAnalysisResult +import com.bhardwaj.passkey.utils.UiText + +data class SettingsState( + val appVersion: String = "", + val autoLockTimeout: AutoLockTimeout = AutoLockTimeout.DEFAULT, + /** Non-null means the bottom sheet is open, and says what it is showing. */ + val sheet: Sheet? = null, + val isAutoLockDialogOpen: Boolean = false, + /** Non-null means the analysis sheet is showing this result. */ + val analysis: PasswordAnalysisResult? = null, + val recoveryChange: RecoveryChangeStep? = null +) { + /** + * The screen has one bottom sheet with two possible bodies. Modelling it as a single + * nullable value replaces an `isSheetOpen` boolean and a separate `bottomSheetOpenedBy` + * that could disagree: dismissing the language picker used to leave the sheet expanded + * showing whichever legal text had been opened last. + */ + sealed interface Sheet { + data object Language : Sheet + data class Info(val topic: AlertBy) : Sheet + } +} + +/** Replaces a `Boolean?` whose three values had to be decoded at every use site. */ +enum class RecoveryChangeStep { CURRENT_PASSWORD, NEW_PASSWORD } + +sealed interface SettingsIntent { + data object LanguageClicked : SettingsIntent + data class LanguageSelected(val language: Language) : SettingsIntent + + data object PrivacyClicked : SettingsIntent + data object TermsClicked : SettingsIntent + data object AboutClicked : SettingsIntent + data object SheetDismissed : SettingsIntent + + data object RateAppClicked : SettingsIntent + + data class ExportFileChosen(val uri: Uri, val password: CharArray) : SettingsIntent { + override fun equals(other: Any?) = this === other || + (other is ExportFileChosen && uri == other.uri && password.contentEquals(other.password)) + + override fun hashCode() = 31 * uri.hashCode() + password.contentHashCode() + } + + /** [password] is null for a legacy plaintext .passkey CSV, which needs none. */ + data class ImportFileChosen( + val uri: Uri, + val password: CharArray?, + val mode: ImportMode + ) : SettingsIntent { + override fun equals(other: Any?) = this === other || + (other is ImportFileChosen && uri == other.uri && + password.contentEquals(other.password) && mode == other.mode) + + override fun hashCode(): Int { + var result = uri.hashCode() + result = 31 * result + (password?.contentHashCode() ?: 0) + return 31 * result + mode.hashCode() + } + } + + data object AnalyzeClicked : SettingsIntent + data object AnalysisDismissed : SettingsIntent + data class AnalysisItemClicked(val previewId: Long) : SettingsIntent + + data object AutoLockClicked : SettingsIntent + data object AutoLockDismissed : SettingsIntent + data class AutoLockTimeoutSelected(val timeout: AutoLockTimeout) : SettingsIntent + + data object ChangeRecoveryPasswordClicked : SettingsIntent + data object RecoveryChangeDismissed : SettingsIntent + data class CurrentRecoveryPasswordEntered(val password: CharArray) : SettingsIntent { + override fun equals(other: Any?) = this === other || + (other is CurrentRecoveryPasswordEntered && password.contentEquals(other.password)) + + override fun hashCode() = password.contentHashCode() + } + + data class NewRecoveryPasswordEntered(val password: CharArray) : SettingsIntent { + override fun equals(other: Any?) = this === other || + (other is NewRecoveryPasswordEntered && password.contentEquals(other.password)) + + override fun hashCode() = password.contentHashCode() + } +} + +sealed interface SettingsEffect { + data class ShowSnackbar(val message: UiText) : SettingsEffect + data class Navigate(val route: NavRoute) : SettingsEffect + + /** + * Opening the store listing needs a visible Activity, so the UI performs it. The old code + * started the intent from the Application context with FLAG_ACTIVITY_NEW_TASK and never + * handled the device having no Play Store, which threw ActivityNotFoundException. + */ + data object OpenStoreListing : SettingsEffect +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt deleted file mode 100644 index 1cea443..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsEvents.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.bhardwaj.passkey.presentation.screens.settings_screen - -import android.net.Uri -import com.bhardwaj.passkey.data.backup.ImportMode -import com.bhardwaj.passkey.domain.model.AutoLockTimeout -import com.bhardwaj.passkey.domain.model.Detail -import com.bhardwaj.passkey.domain.model.Language - -sealed interface SettingsEvents { - data class OnLanguageChange(val newLanguage: Language) : SettingsEvents - /** The user picked a destination and supplied a password for the encrypted backup. */ - data class OnExportFileChosen(val uri: Uri, val password: CharArray) : SettingsEvents { - override fun equals(other: Any?) = - this === other || (other is OnExportFileChosen && uri == other.uri && - password.contentEquals(other.password)) - - override fun hashCode() = 31 * uri.hashCode() + password.contentHashCode() - } - - /** [password] is null for a legacy plaintext .passkey file, which needs none. */ - data class OnImportFileChosen( - val uri: Uri, - val password: CharArray?, - val mode: ImportMode - ) : SettingsEvents { - override fun equals(other: Any?) = - this === other || (other is OnImportFileChosen && uri == other.uri && - password.contentEquals(other.password) && mode == other.mode) - - override fun hashCode(): Int { - var result = uri.hashCode() - result = 31 * result + (password?.contentHashCode() ?: 0) - result = 31 * result + mode.hashCode() - return result - } - } - - data class OnAnalysisItemClick(val detail: Detail) : SettingsEvents - data object OnLanguageClick : SettingsEvents - data object OnDismissBottomSheet : SettingsEvents - data object OnRateAppClick : SettingsEvents - data object OnPrivacyClick : SettingsEvents - data object OnTermsAndConditionClick : SettingsEvents - data object OnAboutClick : SettingsEvents - data object OnAnalyzePasswordsClick : SettingsEvents - data object OnDismissAnalysisSheet : SettingsEvents - data object OnAutoLockClick : SettingsEvents - data object OnChangeRecoveryPasswordClick : SettingsEvents - data object OnDismissRecoveryChange : SettingsEvents - data class OnCurrentRecoveryPasswordEntered(val password: CharArray) : SettingsEvents { - override fun equals(other: Any?) = this === other || - (other is OnCurrentRecoveryPasswordEntered && password.contentEquals(other.password)) - - override fun hashCode() = password.contentHashCode() - } - - data class OnNewRecoveryPasswordEntered(val password: CharArray) : SettingsEvents { - override fun equals(other: Any?) = this === other || - (other is OnNewRecoveryPasswordEntered && password.contentEquals(other.password)) - - override fun hashCode() = password.contentHashCode() - } - data object OnDismissAutoLockDialog : SettingsEvents - data class OnAutoLockTimeoutChange(val timeout: AutoLockTimeout) : SettingsEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index 1b5b14e..7ebb48b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -1,11 +1,9 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen -import com.bhardwaj.passkey.presentation.navigation.NavRoute -import com.bhardwaj.passkey.domain.model.AutoLockTimeout -import android.content.ContentResolver +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent import android.net.Uri -import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -13,30 +11,29 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.ui.window.SecureFlagPolicy -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetValue import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext @@ -45,41 +42,45 @@ 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 androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bhardwaj.passkey.R import com.bhardwaj.passkey.data.backup.BackupFormat import com.bhardwaj.passkey.data.backup.ImportMode -import com.bhardwaj.passkey.presentation.screens.settings_screen.components.BackupPasswordDialog -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bhardwaj.passkey.presentation.screens.settings_screen.components.AutoLockDialog +import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents -import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEvents -import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsViewModel import com.bhardwaj.passkey.presentation.screens.settings_screen.components.AnalysisBottomSheet +import com.bhardwaj.passkey.presentation.screens.settings_screen.components.AutoLockDialog +import com.bhardwaj.passkey.presentation.screens.settings_screen.components.BackupPasswordDialog import com.bhardwaj.passkey.presentation.screens.settings_screen.components.FaqItem import com.bhardwaj.passkey.presentation.screens.settings_screen.components.LanguageBottomSheet import com.bhardwaj.passkey.presentation.screens.settings_screen.components.SettingsText import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins +import com.bhardwaj.passkey.utils.UiText import com.bhardwaj.passkey.utils.asString -import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( - onPopBackStack: () -> Unit, onNavigate: (NavRoute) -> Unit, viewModel: SettingsViewModel = hiltViewModel() ) { + val state by viewModel.state.collectAsStateWithLifecycle() + // A bound method reference allocates a new object on every recomposition, so the lambda + // parameter would never be equal and the whole subtree would recompose needlessly. + val onIntent = remember(viewModel) { viewModel::onIntent } + val snackBarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val scaffoldState = rememberBottomSheetScaffoldState() - val context = LocalContext.current + // Backup file selection. The password is collected only after a destination or source is // chosen, so the user is never asked for one and then cancels out of the picker. var pendingExportUri by remember { mutableStateOf(null) } @@ -97,44 +98,55 @@ fun SettingsScreen( pendingImportUri = it } else { // A legacy plaintext .passkey CSV needs no password; restore it directly. - viewModel.onEvent( - SettingsEvents.OnImportFileChosen( - uri = it, - password = null, - mode = ImportMode.MERGE - ) - ) + onIntent(SettingsIntent.ImportFileChosen(it, password = null, mode = ImportMode.MERGE)) } } } + // The sheet's openness follows the state rather than each click site launching its own + // expand(), so there is one place where the two can no longer disagree. + LaunchedEffect(state.sheet) { + if (state.sheet == null) { + scaffoldState.bottomSheetState.partialExpand() + } else { + scaffoldState.bottomSheetState.expand() + } + } + + // ...and a swipe down is a dismissal like any other, so the state hears about it. + val sheetValue = scaffoldState.bottomSheetState.currentValue + LaunchedEffect(sheetValue) { + if (sheetValue == SheetValue.PartiallyExpanded) { + onIntent(SettingsIntent.SheetDismissed) + } + } + // Two steps: prove the current password, then choose the new one. Reuses the same dialog. - when (viewModel.recoveryChangeStep) { - false -> BackupPasswordDialog( + when (state.recoveryChange) { + RecoveryChangeStep.CURRENT_PASSWORD -> BackupPasswordDialog( confirmMode = false, titleRes = R.string.recovery_change_current_title, messageRes = R.string.recovery_change_current_message, - onDismiss = { viewModel.onEvent(SettingsEvents.OnDismissRecoveryChange) }, - onConfirm = { viewModel.onEvent(SettingsEvents.OnCurrentRecoveryPasswordEntered(it)) } + onDismiss = { onIntent(SettingsIntent.RecoveryChangeDismissed) }, + onConfirm = { onIntent(SettingsIntent.CurrentRecoveryPasswordEntered(it)) } ) - true -> BackupPasswordDialog( + RecoveryChangeStep.NEW_PASSWORD -> BackupPasswordDialog( confirmMode = true, titleRes = R.string.recovery_change_new_title, messageRes = R.string.recovery_change_new_message, - onDismiss = { viewModel.onEvent(SettingsEvents.OnDismissRecoveryChange) }, - onConfirm = { viewModel.onEvent(SettingsEvents.OnNewRecoveryPasswordEntered(it)) } + onDismiss = { onIntent(SettingsIntent.RecoveryChangeDismissed) }, + onConfirm = { onIntent(SettingsIntent.NewRecoveryPasswordEntered(it)) } ) null -> Unit } - val autoLockTimeout by viewModel.autoLockTimeout.collectAsStateWithLifecycle() - if (viewModel.isAutoLockDialogOpen) { + if (state.isAutoLockDialogOpen) { AutoLockDialog( - current = autoLockTimeout, - onDismiss = { viewModel.onEvent(SettingsEvents.OnDismissAutoLockDialog) }, - onSelect = { viewModel.onEvent(SettingsEvents.OnAutoLockTimeoutChange(it)) } + current = state.autoLockTimeout, + onDismiss = { onIntent(SettingsIntent.AutoLockDismissed) }, + onSelect = { onIntent(SettingsIntent.AutoLockTimeoutSelected(it)) } ) } @@ -144,7 +156,7 @@ fun SettingsScreen( onDismiss = { pendingExportUri = null }, onConfirm = { password -> pendingExportUri = null - viewModel.onEvent(SettingsEvents.OnExportFileChosen(uri, password)) + onIntent(SettingsIntent.ExportFileChosen(uri, password)) } ) } @@ -155,30 +167,38 @@ fun SettingsScreen( onDismiss = { pendingImportUri = null }, onConfirm = { password -> pendingImportUri = null - viewModel.onEvent( - SettingsEvents.OnImportFileChosen(uri, password, ImportMode.MERGE) - ) + onIntent(SettingsIntent.ImportFileChosen(uri, password, ImportMode.MERGE)) } ) } - val isAnalysisSheetOpen = viewModel.isAnalysisSheetOpen - val analysisResult = viewModel.analysisResult + ObserveAsEvents(viewModel.effects) { effect -> + when (effect) { + is SettingsEffect.Navigate -> onNavigate(effect.route) + + is SettingsEffect.ShowSnackbar -> scope.launch { + snackBarHostState.showSnackbar(message = effect.message.asString(context)) + } - ObserveAsEvents(viewModel.uiEvents) { event -> - when (event) { - is UiEvents.PopBackStack -> onPopBackStack() - is UiEvents.Navigate -> onNavigate(event.route) - is UiEvents.ShowSnackBar -> { - scope.launch { - snackBarHostState.showSnackbar( - message = event.message.asString(context), - actionLabel = event.action?.asString(context) + // Started from the Activity context, and guarded: a device without the Play Store + // used to take an unhandled ActivityNotFoundException straight to a crash. + SettingsEffect.OpenStoreListing -> { + val opened = runCatching { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + "https://play.google.com/store/apps/details?id=${context.packageName}".toUri() + ) ) + }.isSuccess + if (!opened) { + scope.launch { + snackBarHostState.showSnackbar( + UiText.StringResource(R.string.store_unavailable).asString(context) + ) + } } } - - else -> Unit } } @@ -193,32 +213,19 @@ fun SettingsScreen( sheetSwipeEnabled = true, sheetDragHandle = {}, sheetContent = { - if (viewModel.isSheetOpen) { - val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - properties = ModalBottomSheetProperties( - securePolicy = SecureFlagPolicy.SecureOn - ), - sheetState = sheetState, - onDismissRequest = { viewModel.onEvent(SettingsEvents.OnDismissBottomSheet) }, - dragHandle = {}, - shape = RectangleShape, - ) { - LanguageBottomSheet( - onLanguageChange = { - viewModel.onEvent( - SettingsEvents.OnLanguageChange( - newLanguage = it - ) - ) - }, - onBackIconClick = { - viewModel.onEvent(SettingsEvents.OnDismissBottomSheet) - } - ) - } - } else { - FaqItem(openedBy = viewModel.bottomSheetOpenedBy) + // Rendered inside the scaffold's own sheet rather than in a nested + // ModalBottomSheet: that put a second window in front of an empty expanded + // sheet, and only the activity window carries FLAG_SECURE reliably. + when (val sheet = state.sheet) { + SettingsState.Sheet.Language -> LanguageBottomSheet( + onLanguageChange = { onIntent(SettingsIntent.LanguageSelected(it)) }, + onBackIconClick = { onIntent(SettingsIntent.SheetDismissed) } + ) + + is SettingsState.Sheet.Info -> FaqItem(openedBy = sheet.topic) + + // A sheet needs measurable content even while collapsed. + null -> Spacer(modifier = Modifier.fillMaxWidth().height(1.dp)) } } ) { @@ -259,22 +266,21 @@ fun SettingsScreen( .padding(top = 16.dp, start = 16.dp, end = 16.dp), ) { SettingsText(text = stringResource(id = R.string.change_language)) { - viewModel.onEvent(SettingsEvents.OnLanguageClick) - scope.launch { scaffoldState.bottomSheetState.expand() } + onIntent(SettingsIntent.LanguageClicked) } SettingsText(text = stringResource(id = R.string.analyze_passwords)) { - viewModel.onEvent(SettingsEvents.OnAnalyzePasswordsClick) + onIntent(SettingsIntent.AnalyzeClicked) } SettingsText(text = stringResource(id = R.string.rate_app)) { - viewModel.onEvent(SettingsEvents.OnRateAppClick) + onIntent(SettingsIntent.RateAppClicked) } SettingsText(text = stringResource(id = R.string.auto_lock)) { - viewModel.onEvent(SettingsEvents.OnAutoLockClick) + onIntent(SettingsIntent.AutoLockClicked) } SettingsText( text = stringResource(id = R.string.change_recovery_password) ) { - viewModel.onEvent(SettingsEvents.OnChangeRecoveryPasswordClick) + onIntent(SettingsIntent.ChangeRecoveryPasswordClicked) } SettingsText(text = stringResource(id = R.string.import_data)) { importLauncher.launch(arrayOf("*/*")) @@ -283,22 +289,19 @@ fun SettingsScreen( exportLauncher.launch(defaultBackupFileName()) } SettingsText(text = stringResource(id = R.string.privacy)) { - viewModel.onEvent(SettingsEvents.OnPrivacyClick) - scope.launch { scaffoldState.bottomSheetState.expand() } + onIntent(SettingsIntent.PrivacyClicked) } SettingsText(text = stringResource(id = R.string.terms_n_condition)) { - viewModel.onEvent(SettingsEvents.OnTermsAndConditionClick) - scope.launch { scaffoldState.bottomSheetState.expand() } + onIntent(SettingsIntent.TermsClicked) } SettingsText(text = stringResource(id = R.string.about)) { - viewModel.onEvent(SettingsEvents.OnAboutClick) - scope.launch { scaffoldState.bottomSheetState.expand() } + onIntent(SettingsIntent.AboutClicked) } } } Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.app_version, viewModel.appVersion), + text = stringResource(id = R.string.app_version, state.appVersion), fontFamily = Poppins, fontSize = 12.sp, color = MaterialTheme.colorScheme.outline, @@ -308,12 +311,13 @@ fun SettingsScreen( } } } - if (isAnalysisSheetOpen) { + + state.analysis?.let { analysis -> AnalysisBottomSheet( - result = analysisResult, - onDismiss = { viewModel.onEvent(SettingsEvents.OnDismissAnalysisSheet) }, + result = analysis, + onDismiss = { onIntent(SettingsIntent.AnalysisDismissed) }, onDetailClick = { detail -> - viewModel.onEvent(SettingsEvents.OnAnalysisItemClick(detail)) + onIntent(SettingsIntent.AnalysisItemClicked(detail.previewId)) } ) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index 8818d44..8746499 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -1,49 +1,36 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen -import com.bhardwaj.passkey.presentation.navigation.NavRoute -import android.app.Application -import android.app.LocaleManager -import android.content.Intent -import android.os.Build -import android.os.LocaleList -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.net.toUri -import androidx.core.os.LocaleListCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.data.AppInfo +import com.bhardwaj.passkey.data.analysis.SecretKeywordProvider import com.bhardwaj.passkey.data.backup.BackupError import com.bhardwaj.passkey.data.backup.BackupException import com.bhardwaj.passkey.data.backup.BackupRepository -import com.bhardwaj.passkey.domain.model.AutoLockTimeout +import com.bhardwaj.passkey.data.locale.AppLocaleManager import com.bhardwaj.passkey.data.security.DatabaseKeyManager -import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.di.DefaultDispatcher import com.bhardwaj.passkey.domain.repository.PasskeyRepository -import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEvents -import com.bhardwaj.passkey.utils.AlertBy.ABOUT -import com.bhardwaj.passkey.utils.AlertBy.PRIVACY -import com.bhardwaj.passkey.utils.AlertBy.TERMS_N_CONDITIONS -import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.presentation.navigation.NavRoute +import com.bhardwaj.passkey.utils.AlertBy import com.bhardwaj.passkey.utils.PasswordAnalysisResult import com.bhardwaj.passkey.utils.PasswordAnalyzer -import com.bhardwaj.passkey.utils.UiEvents import com.bhardwaj.passkey.utils.UiText import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Arrays import javax.inject.Inject @HiltViewModel @@ -52,186 +39,163 @@ class SettingsViewModel @Inject constructor( private val preferences: PreferencesRepository, private val backupRepository: BackupRepository, private val keyManager: DatabaseKeyManager, - private val appContext: Application + private val localeManager: AppLocaleManager, + private val secretKeywords: SecretKeywordProvider, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + appInfo: AppInfo ) : ViewModel() { + // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded // screen has no active collector, and a rendezvous channel would suspend the coroutine // that emitted the effect until the user came back. - private val _uiEvents = Channel(Channel.BUFFERED) - val uiEvents = _uiEvents.receiveAsFlow() - - var appVersion: String by mutableStateOf( - appContext.packageManager.getPackageInfo( - appContext.packageName, - 0 - ).versionName ?: "1.0.0" - ) - private set + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() - var bottomSheetOpenedBy by mutableStateOf(PRIVACY) - private set + private val transient = MutableStateFlow(TransientState()) - var isSheetOpen by mutableStateOf(false) - private set - - var isAnalysisSheetOpen by mutableStateOf(false) - private set - - var analysisResult by mutableStateOf(PasswordAnalysisResult()) - private set - - var isAutoLockDialogOpen by mutableStateOf(false) - private set - - /** null = closed, false = asking for the current password, true = asking for the new one. */ - var recoveryChangeStep by mutableStateOf(null) - private set + private data class TransientState( + val sheet: SettingsState.Sheet? = null, + val isAutoLockDialogOpen: Boolean = false, + val analysis: PasswordAnalysisResult? = null, + val recoveryChange: RecoveryChangeStep? = null + ) - /** Held only between the two dialog steps, then zeroed. */ + /** + * Held only between the two recovery dialog steps, then zeroed - never in [SettingsState], + * which the composition retains. + */ private var pendingCurrentRecoveryPassword: CharArray? = null - val autoLockTimeout: StateFlow = preferences.autoLockTimeout - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AutoLockTimeout.DEFAULT) - - fun onEvent(event: SettingsEvents) { - when (event) { - SettingsEvents.OnLanguageClick -> { - isSheetOpen = true - } + private val appVersion = appInfo.versionName + + val state: StateFlow = combine( + transient, + preferences.autoLockTimeout + ) { ui, timeout -> + SettingsState( + appVersion = appVersion, + autoLockTimeout = timeout, + sheet = ui.sheet, + isAutoLockDialogOpen = ui.isAutoLockDialogOpen, + analysis = ui.analysis, + recoveryChange = ui.recoveryChange + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + SettingsState(appVersion = appVersion) + ) - SettingsEvents.OnDismissBottomSheet -> { - isSheetOpen = false - } + fun onIntent(intent: SettingsIntent) { + when (intent) { + SettingsIntent.LanguageClicked -> + transient.update { it.copy(sheet = SettingsState.Sheet.Language) } - SettingsEvents.OnRateAppClick -> { - val appPackageName = appContext.packageName - val intent = - Intent( - Intent.ACTION_VIEW, - "https://play.google.com/store/apps/details?id=$appPackageName".toUri() - ) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - appContext.startActivity(intent) - } + SettingsIntent.PrivacyClicked -> openInfo(AlertBy.PRIVACY) + SettingsIntent.TermsClicked -> openInfo(AlertBy.TERMS_N_CONDITIONS) + SettingsIntent.AboutClicked -> openInfo(AlertBy.ABOUT) - SettingsEvents.OnPrivacyClick -> { - bottomSheetOpenedBy = PRIVACY - } + SettingsIntent.SheetDismissed -> transient.update { it.copy(sheet = null) } - SettingsEvents.OnTermsAndConditionClick -> { - bottomSheetOpenedBy = TERMS_N_CONDITIONS + is SettingsIntent.LanguageSelected -> { + transient.update { it.copy(sheet = null) } + viewModelScope.launch { localeManager.apply(intent.language.languageId) } } - SettingsEvents.OnAboutClick -> { - bottomSheetOpenedBy = ABOUT - } + SettingsIntent.RateAppClicked -> emit(SettingsEffect.OpenStoreListing) - is SettingsEvents.OnLanguageChange -> { - isSheetOpen = false - if (event.newLanguage.comingSoon) { - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource(R.string.coming_soon) + is SettingsIntent.ExportFileChosen -> viewModelScope.launch { + val result = backupRepository.export(intent.uri, intent.password) + Arrays.fill(intent.password, Char(0)) + emit( + SettingsEffect.ShowSnackbar( + UiText.StringResource( + if (result.isSuccess) R.string.export_success + else R.string.export_failed ) ) - } else { - viewModelScope.launch { - preferences.setSelectedLanguageTag(event.newLanguage.languageId) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - appContext.getSystemService(LocaleManager::class.java).applicationLocales = - LocaleList.forLanguageTags(event.newLanguage.languageId) - } else { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags( - event.newLanguage.languageId + ) + } + + is SettingsIntent.ImportFileChosen -> viewModelScope.launch { + val result = backupRepository.import(intent.uri, intent.password, intent.mode) + intent.password?.let { Arrays.fill(it, Char(0)) } + result.fold( + onSuccess = { summary -> + emit( + SettingsEffect.ShowSnackbar( + UiText.StringResource( + R.string.import_summary, + listOf(summary.previewsAdded, summary.detailsAdded) ) ) + ) + }, + onFailure = { error -> + val message = when ((error as? BackupException)?.error) { + BackupError.WrongPasswordOrCorrupt -> R.string.import_wrong_password + BackupError.FileTooLarge -> R.string.import_file_too_large + is BackupError.UnsupportedVersion -> R.string.import_unsupported_version + else -> R.string.import_failed } + emit(SettingsEffect.ShowSnackbar(UiText.StringResource(message))) } - } + ) } - is SettingsEvents.OnExportFileChosen -> { - viewModelScope.launch { - val result = backupRepository.export(event.uri, event.password) - java.util.Arrays.fill(event.password, '\u0000') - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource( - if (result.isSuccess) R.string.export_success - else R.string.export_failed - ) - ) - ) + SettingsIntent.AnalyzeClicked -> viewModelScope.launch { + val allDetails = repository.getDetails().first() + val keywords = secretKeywords.keywords() + // Analysis walks the whole vault, so it is moved off the main thread. + val result = withContext(defaultDispatcher) { + PasswordAnalyzer.analyze(allDetails, keywords) } + transient.update { it.copy(analysis = result) } } - is SettingsEvents.OnImportFileChosen -> { - viewModelScope.launch { - val result = backupRepository.import(event.uri, event.password, event.mode) - event.password?.let { java.util.Arrays.fill(it, '\u0000') } - result.fold( - onSuccess = { summary -> - sendUiEvents( - UiEvents.ShowSnackBar( - message = UiText.StringResource( - R.string.import_summary, - listOf(summary.previewsAdded, summary.detailsAdded) - ) - ) - ) - }, - onFailure = { error -> - val message = when ((error as? BackupException)?.error) { - BackupError.WrongPasswordOrCorrupt -> R.string.import_wrong_password - BackupError.FileTooLarge -> R.string.import_file_too_large - is BackupError.UnsupportedVersion -> R.string.import_unsupported_version - else -> R.string.import_failed - } - sendUiEvents(UiEvents.ShowSnackBar(UiText.StringResource(message))) - } - ) - } - } + SettingsIntent.AnalysisDismissed -> transient.update { it.copy(analysis = null) } - SettingsEvents.OnAnalyzePasswordsClick -> { - viewModelScope.launch { - val allDetails = repository.getDetails().first() - val keywords = secretFieldKeywords() - val result = withContext(Dispatchers.IO) { - PasswordAnalyzer.analyze(allDetails, keywords) - } - analysisResult = result - isAnalysisSheetOpen = true - } + is SettingsIntent.AnalysisItemClicked -> { + transient.update { it.copy(analysis = null) } + emit(SettingsEffect.Navigate(NavRoute.Details(previewId = intent.previewId))) } - SettingsEvents.OnChangeRecoveryPasswordClick -> { - recoveryChangeStep = false + SettingsIntent.AutoLockClicked -> + transient.update { it.copy(isAutoLockDialogOpen = true) } + + SettingsIntent.AutoLockDismissed -> + transient.update { it.copy(isAutoLockDialogOpen = false) } + + is SettingsIntent.AutoLockTimeoutSelected -> { + transient.update { it.copy(isAutoLockDialogOpen = false) } + viewModelScope.launch { preferences.setAutoLockTimeout(intent.timeout) } } - SettingsEvents.OnDismissRecoveryChange -> { - pendingCurrentRecoveryPassword?.let { java.util.Arrays.fill(it, Char(0)) } - pendingCurrentRecoveryPassword = null - recoveryChangeStep = null + SettingsIntent.ChangeRecoveryPasswordClicked -> + transient.update { it.copy(recoveryChange = RecoveryChangeStep.CURRENT_PASSWORD) } + + SettingsIntent.RecoveryChangeDismissed -> { + clearPendingRecoveryPassword() + transient.update { it.copy(recoveryChange = null) } } - is SettingsEvents.OnCurrentRecoveryPasswordEntered -> { - pendingCurrentRecoveryPassword = event.password - recoveryChangeStep = true + is SettingsIntent.CurrentRecoveryPasswordEntered -> { + clearPendingRecoveryPassword() + pendingCurrentRecoveryPassword = intent.password + transient.update { it.copy(recoveryChange = RecoveryChangeStep.NEW_PASSWORD) } } - is SettingsEvents.OnNewRecoveryPasswordEntered -> { + is SettingsIntent.NewRecoveryPasswordEntered -> { val current = pendingCurrentRecoveryPassword pendingCurrentRecoveryPassword = null - recoveryChangeStep = null + transient.update { it.copy(recoveryChange = null) } viewModelScope.launch { val changed = current != null && - keyManager.changeRecoveryPassword(current, event.password) - current?.let { java.util.Arrays.fill(it, Char(0)) } - java.util.Arrays.fill(event.password, Char(0)) - sendUiEvents( - UiEvents.ShowSnackBar( + keyManager.changeRecoveryPassword(current, intent.password) + current?.let { Arrays.fill(it, Char(0)) } + Arrays.fill(intent.password, Char(0)) + emit( + SettingsEffect.ShowSnackbar( UiText.StringResource( if (changed) R.string.recovery_change_done else R.string.recovery_change_failed @@ -240,58 +204,32 @@ class SettingsViewModel @Inject constructor( ) } } - - SettingsEvents.OnAutoLockClick -> { - isAutoLockDialogOpen = true - } - - SettingsEvents.OnDismissAutoLockDialog -> { - isAutoLockDialogOpen = false - } - - is SettingsEvents.OnAutoLockTimeoutChange -> { - isAutoLockDialogOpen = false - viewModelScope.launch { - preferences.setAutoLockTimeout(event.timeout) - } - } - - SettingsEvents.OnDismissAnalysisSheet -> { - isAnalysisSheetOpen = false - } - - is SettingsEvents.OnAnalysisItemClick -> { - isAnalysisSheetOpen = false - sendUiEvents( - UiEvents.Navigate(NavRoute.Details(previewId = event.detail.previewId)) - ) - } } } /** - * Union of the default-locale and current-locale keyword lists. A vault may hold entries - * labelled before the user switched language, so matching only the current locale would - * silently stop classifying them. + * Abandoning the flow half-way used to leave the entered password in memory for the + * ViewModel's remaining life. */ - private fun secretFieldKeywords(): Set { - val current = appContext.resources.getStringArray(R.array.secret_field_keywords).toSet() - val defaultLocaleConfig = android.content.res.Configuration(appContext.resources.configuration) - defaultLocaleConfig.setLocale(java.util.Locale.ENGLISH) - val fallback = appContext.createConfigurationContext(defaultLocaleConfig) - .resources.getStringArray(R.array.secret_field_keywords).toSet() - return current + fallback + override fun onCleared() { + clearPendingRecoveryPassword() + super.onCleared() } - private fun sendUiEvents(events: UiEvents) { - viewModelScope.launch { - _uiEvents.send(events) - } + private fun clearPendingRecoveryPassword() { + pendingCurrentRecoveryPassword?.let { Arrays.fill(it, Char(0)) } + pendingCurrentRecoveryPassword = null } + private fun openInfo(topic: AlertBy) { + transient.update { it.copy(sheet = SettingsState.Sheet.Info(topic)) } + } + private fun emit(effect: SettingsEffect) { + viewModelScope.launch { _effects.send(effect) } + } +} - - - -} \ No newline at end of file +private inline fun MutableStateFlow.update(transform: (T) -> T) { + value = transform(value) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt index 9dafd94..28e0620 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt @@ -68,104 +68,87 @@ fun LanguageBottomSheet( Language( languageId = "ar", languageName = "عربي", - languageNameInEnglish = "Arabic", - comingSoon = false + languageNameInEnglish = "Arabic" ), Language( languageId = "bn", languageName = "বাংলা", - languageNameInEnglish = "Bengali", - comingSoon = false + languageNameInEnglish = "Bengali" ), Language( languageId = "zh", languageName = "中文", - languageNameInEnglish = "Chinese", - comingSoon = false + languageNameInEnglish = "Chinese" ), Language( languageId = "en", languageName = "English", - languageNameInEnglish = "English", - comingSoon = false + languageNameInEnglish = "English" ), Language( languageId = "fr", languageName = "Français", - languageNameInEnglish = "French", - comingSoon = false + languageNameInEnglish = "French" ), Language( languageId = "de", languageName = "Deutsch", - languageNameInEnglish = "German", - comingSoon = false + languageNameInEnglish = "German" ), Language( languageId = "gu", languageName = "ગુજરાતી", - languageNameInEnglish = "Gujarati", - comingSoon = false + languageNameInEnglish = "Gujarati" ), Language( languageId = "hi", languageName = "हिन्दी", - languageNameInEnglish = "Hindi", - comingSoon = false + languageNameInEnglish = "Hindi" ), Language( languageId = "it", languageName = "Italiano", - languageNameInEnglish = "Italian", - comingSoon = false + languageNameInEnglish = "Italian" ), Language( languageId = "ja", languageName = "日本語", - languageNameInEnglish = "Japanese", - comingSoon = false + languageNameInEnglish = "Japanese" ), Language( languageId = "ko", languageName = "한국어", - languageNameInEnglish = "Korean", - comingSoon = false + languageNameInEnglish = "Korean" ), Language( languageId = "mr", languageName = "मराठी", - languageNameInEnglish = "Marathi", - comingSoon = false + languageNameInEnglish = "Marathi" ), Language( languageId = "pt", languageName = "Português", - languageNameInEnglish = "Portuguese", - comingSoon = false + languageNameInEnglish = "Portuguese" ), Language( languageId = "ru", languageName = "Русский", - languageNameInEnglish = "Russian", - comingSoon = false + languageNameInEnglish = "Russian" ), Language( languageId = "es", languageName = "Español", - languageNameInEnglish = "Spanish", - comingSoon = false + languageNameInEnglish = "Spanish" ), Language( languageId = "ta", languageName = "தமிழ்", - languageNameInEnglish = "Tamil", - comingSoon = false + languageNameInEnglish = "Tamil" ), Language( languageId = "te", languageName = "తెలుగు", - languageNameInEnglish = "Telugu", - comingSoon = false + languageNameInEnglish = "Telugu" ), ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 135350e..3db0347 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -64,7 +64,6 @@ تم قفل PassKey المصادقة مطلوبة للوصول إلى تطبيق PassKey. فتح الآن - قريبًا تأكيد الحذف هل أنت متأكد أنك تريد حذف السؤال؟ هل أنت متأكد أنك تريد حذف العنوان؟ @@ -166,4 +165,5 @@ كلمة المرور غير صحيحة. صدّر نسخة احتياطية أولًا صدّر خزنتي الآن + خدمة Google Play غير متوفرة على هذا الجهاز. \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 4930c43..2b229d6 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -64,7 +64,6 @@ PassKey লক হয়েছে PassKey অ্যাপ্লিকেশনে অ্যাক্সেস করতে প্রাধিকারীকরণ প্রয়োজন। এখন আনলক করুন - শীঘ্রই আসছে মুছে ফেলার নিশ্চিতকরণ আপনি কি নিশ্চিত যে আপনি প্রশ্নটি মুছে ফেলতে চান? আপনি কি নিশ্চিত যে আপনি শিরোনামটি মুছে ফেলতে চান? @@ -162,4 +161,5 @@ পাসওয়ার্ডটি সঠিক ছিল না। আগে ব্যাকআপ নিন এখনই আমার ভল্ট রপ্তানি করুন + এই ডিভাইসে Google Play উপলব্ধ নেই। diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5f65c29..9ef0682 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -64,7 +64,6 @@ PassKey ist gesperrt Zur Nutzung der PassKey-App ist eine Authentifizierung erforderlich. Jetzt entsperren - Demnächst verfügbar Bestätigen Sie die Löschung. Sind Sie sicher, dass Sie die Frage löschen möchten? Sind Sie sicher, dass Sie die Überschrift löschen möchten? @@ -163,4 +162,5 @@ Das Passwort war nicht korrekt. Zuerst ein Backup exportieren Tresor jetzt exportieren + Google Play ist auf diesem Gerät nicht verfügbar. \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 779bcde..86162b3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -64,7 +64,6 @@ PassKey está bloqueado Se requiere autenticación para acceder a la aplicación PassKey. Desbloquear ahora - Próximamente Confirmar eliminación ¿Estás seguro de que quieres eliminar la pregunta? ¿Estás seguro de que quieres eliminar el encabezado? @@ -163,4 +162,5 @@ Esa contraseña no era correcta. Exportar una copia primero Exportar mi caja fuerte ahora + Google Play no está disponible en este dispositivo. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a0677b4..c6afcf6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -64,7 +64,6 @@ PassKey est verrouillé Une authentification est requise pour accéder à l\'application PassKey. Déverrouiller maintenant - Bientôt disponible Confirmer la suppression Êtes-vous sûr de vouloir supprimer la question ? Êtes-vous sûr de vouloir supprimer l\'en-tête ? @@ -163,4 +162,5 @@ Ce mot de passe n\'était pas correct. Exporter une sauvegarde d\'abord Exporter mon coffre maintenant + Google Play n\'est pas disponible sur cet appareil. diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index c43ceac..5641b15 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -64,7 +64,6 @@ પાસકી લોકાંતિક છે પાસકી એપ્લિકેશન એક્સેસ માટે પ્રમાણીકરણ જરૂરી છે. હાલ ખોલો - શીઘ્રવાર આવી રહ્યું છે ડિલીટ કન્ફર્મ કરો શું તમે ખાતરી છો કે તમે પ્રશ્ન ડિલીટ કરવા માંગો છો? શું તમે ખાતરી છો કે તમે શીર્ષક ડિલીટ કરવા માંગો છો? @@ -161,4 +160,5 @@ એ પાસવર્ડ સાચો ન હતો. પહેલાં બૅકઅપ લો હમણાં મારી વૉલ્ટ નિકાસ કરો + આ ઉપકરણ પર Google Play ઉપલબ્ધ નથી. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 7f266ca..e8d5a7a 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -64,7 +64,6 @@ पासकी ताला बंद है पासकी ऐप्प तक पहुँचने के लिए प्रमाणीकरण की आवश्यकता है। अब खोलें - जल्द ही आ रहा है हटाने की पुष्टि करें क्या आप वाकई प्रश्न हटाना चाहते हैं? क्या आप वाकई शीर्षक हटाना चाहते हैं? @@ -162,4 +161,5 @@ वह पासवर्ड सही नहीं था। पहले बैकअप निर्यात करें मेरी वॉल्ट अभी निर्यात करें + इस डिवाइस पर Google Play उपलब्ध नहीं है। \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7658811..8aa4965 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -64,7 +64,6 @@ PassKey è bloccato È necessaria un\'autenticazione per accedere all\'app PassKey. Sblocca ora - Prossimamente Conferma eliminazione Sei sicuro di voler eliminare la domanda? Sei sicuro di voler eliminare l\'intestazione? @@ -163,4 +162,5 @@ Quella password non era corretta. Esporta prima un backup Esporta la mia cassaforte ora + Google Play non è disponibile su questo dispositivo. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7f21b98..e5ba4f1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -64,7 +64,6 @@ PassKeyはロックされています PassKeyアプリにアクセスするには認証が必要です。 今すぐアンロック - 近日公開 削除を確認 本当に質問を削除しますか? 本当に見出しを削除しますか? @@ -160,4 +159,5 @@ パスワードが正しくありませんでした。 先にバックアップを書き出す 今すぐ保管庫を書き出す + このデバイスでは Google Play を利用できません。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2c96486..52106e0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -64,7 +64,6 @@ PassKey가 잠겨 있습니다 PassKey 앱에 액세스하려면 인증이 필요합니다. 지금 잠금 해제 - 곧 출시 예정 삭제 확인 질문을 삭제하시겠습니까? 제목을 삭제하시겠습니까? @@ -161,4 +160,5 @@ 비밀번호가 올바르지 않습니다. 먼저 백업 내보내기 지금 보관함 내보내기 + 이 기기에서는 Google Play를 사용할 수 없습니다. \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 535c44f..e18ddb1 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -64,7 +64,6 @@ पासकी लॉक केला गेला आहे पासकी अ‍ॅपमध्ये प्रवेश करण्यासाठी प्रमाणीकरण आवश्यक आहे. आता अनलॉक करा - शीघ्र येत आहे हटवण्याची पुष्टी तुम्ही का प्रश्न हटवू इच्छिता? तुम्ही का शीर्षक हटवू इच्छिता? @@ -162,4 +161,5 @@ तो पासवर्ड बरोबर नव्हता. आधी बॅकअप घ्या माझी व्हॉल्ट आता निर्यात करा + या डिव्हाइसवर Google Play उपलब्ध नाही. \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c5b4d16..9d79f23 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -64,7 +64,6 @@ PassKey está bloqueado A autenticação é necessária para acessar o aplicativo PassKey. Desbloquear agora - Em breve Confirmar exclusão Tem certeza de que deseja excluir a pergunta? Tem certeza de que deseja excluir o cabeçalho? @@ -163,4 +162,5 @@ Essa senha não estava correta. Exportar um backup antes Exportar meu cofre agora + O Google Play não está disponível neste dispositivo. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 98caced..670dfb0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -64,7 +64,6 @@ PassKey заблокирован Для доступа к приложению PassKey требуется аутентификация. Разблокировать сейчас - Скоро Подтвердите удаление Вы уверены, что хотите удалить вопрос? Вы уверены, что хотите удалить заголовок? @@ -163,4 +162,5 @@ Этот пароль неверен. Сначала выгрузить копию Выгрузить хранилище сейчас + Google Play недоступен на этом устройстве. diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 549c1d4..a3346bf 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -64,7 +64,6 @@ பாஸ்கீ பாதுகாப்பு முதல் செயல் பாஸ்கீ பயன்பாட்டில் அணுகுமுறை தேவைப்படுகின்றது. இப்போது விடுக - விரைவில் வருகின்றது நீக்குவதை உறுதிப்படுத்தவும் நீங்கள் கேள்வியை நீக்க உறுதியாக உள்ளீர்களா? நீங்கள் தலைப்புயை நீக்க உறுதியாக உள்ளீர்களா? @@ -161,4 +160,5 @@ அந்தக் கடவுச்சொல் சரியில்லை. முதலில் காப்புப்பிரதி எடு என் பெட்டகத்தை இப்போது ஏற்றுமதி செய் + இந்தச் சாதனத்தில் Google Play கிடைக்கவில்லை. diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index a9c6f73..ec67e15 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -64,7 +64,6 @@ పాస్‌కీ లాక్ చేయబడింది పాస్‌కీ అనువర్తనంకు ప్రవేశించడానికి ధృవీకరణ అవసరం. ఇప్పుడు అన్‌లాక్ చేయండి - త్వరలో తొలగించు నిర్ధారించండి ఖచ్చితంగా మీరు ప్రశ్నను తొలగించాలా? ఖచ్చితంగా మీరు శీర్షికను తొలగించాలా? @@ -161,5 +160,6 @@ ఆ పాస్‌వర్డ్ సరైనది కాదు. ముందు బ్యాకప్ తీసుకోండి నా వాల్ట్‌ను ఇప్పుడే ఎగుమతి చేయి + ఈ పరికరంలో Google Play అందుబాటులో లేదు. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index eb422ca..9375bc0 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -64,7 +64,6 @@ PassKey已锁定 需要验证才能访问PassKey应用。 立即解锁 - 即将推出 确认删除 您确定要删除问题吗? 您确定要删除标题吗? @@ -161,4 +160,5 @@ 该密码不正确。 先导出备份 立即导出我的密码库 + 此设备上无法使用 Google Play。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 329c0fe..0a89479 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,7 +114,6 @@ Questions: contact yrkkh.cclub@gm PassKey is locked Authentication is required to access the PassKey app. Unlock now - Coming Soon Confirm delete Are you sure you want to delete question? Are you sure you want to delete heading? @@ -236,4 +235,5 @@ Questions: contact yrkkh.cclub@gm Export a backup first Export my vault now + Google Play is not available on this device. \ No newline at end of file diff --git a/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt b/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt new file mode 100644 index 0000000..b44b485 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt @@ -0,0 +1,272 @@ +package com.bhardwaj.passkey + +import android.net.Uri +import app.cash.turbine.test +import com.bhardwaj.passkey.data.AppInfo +import com.bhardwaj.passkey.data.analysis.SecretKeywordProvider +import com.bhardwaj.passkey.data.backup.BackupError +import com.bhardwaj.passkey.data.backup.BackupException +import com.bhardwaj.passkey.data.backup.BackupRepository +import com.bhardwaj.passkey.data.backup.ImportMode +import com.bhardwaj.passkey.data.locale.AppLocaleManager +import com.bhardwaj.passkey.data.security.DatabaseKeyManager +import com.bhardwaj.passkey.domain.model.AutoLockTimeout +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Language +import com.bhardwaj.passkey.domain.repository.PreferencesRepository +import com.bhardwaj.passkey.presentation.navigation.NavRoute +import com.bhardwaj.passkey.presentation.screens.settings_screen.RecoveryChangeStep +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsEffect +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsIntent +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsState +import com.bhardwaj.passkey.presentation.screens.settings_screen.SettingsViewModel +import com.bhardwaj.passkey.utils.AlertBy +import com.bhardwaj.passkey.utils.UiText +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + private lateinit var repository: FakePasskeyRepository + private lateinit var preferences: FakePreferencesRepository + private lateinit var backupRepository: BackupRepository + private lateinit var keyManager: DatabaseKeyManager + private lateinit var localeManager: AppLocaleManager + private lateinit var secretKeywords: SecretKeywordProvider + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = FakePasskeyRepository() + preferences = FakePreferencesRepository() + backupRepository = mockk(relaxed = true) + keyManager = mockk(relaxed = true) + localeManager = mockk(relaxed = true) + secretKeywords = mockk() + every { secretKeywords.keywords() } returns setOf("password") + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel() = SettingsViewModel( + repository = repository, + preferences = preferences, + backupRepository = backupRepository, + keyManager = keyManager, + localeManager = localeManager, + secretKeywords = secretKeywords, + defaultDispatcher = dispatcher, + appInfo = AppInfo() + ) + + @Test + fun `dismissing the language sheet leaves no sheet showing`() = runTest(dispatcher) { + // The old pair of fields disagreed here: isSheetOpen went false while + // bottomSheetOpenedBy still held a topic, so closing the language picker revealed + // whichever legal text had been opened last. + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(SettingsIntent.PrivacyClicked) + assertThat(expectMostRecentItem().sheet) + .isEqualTo(SettingsState.Sheet.Info(AlertBy.PRIVACY)) + + vm.onIntent(SettingsIntent.LanguageClicked) + assertThat(expectMostRecentItem().sheet).isEqualTo(SettingsState.Sheet.Language) + + vm.onIntent(SettingsIntent.SheetDismissed) + assertThat(expectMostRecentItem().sheet).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `choosing a language applies the tag and closes the sheet`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(SettingsIntent.LanguageClicked) + vm.onIntent( + SettingsIntent.LanguageSelected( + Language(languageId = "hi", languageName = "हिन्दी", languageNameInEnglish = "Hindi") + ) + ) + assertThat(expectMostRecentItem().sheet).isNull() + cancelAndIgnoreRemainingEvents() + } + coVerify { localeManager.apply("hi") } + } + + @Test + fun `analysis opens a sheet and tapping an entry navigates to it`() = runTest(dispatcher) { + val previewId = repository.createPreview("Gmail", Category.MAILS, sequence = 0) + repository.createDetail(previewId = previewId, question = "Password", answer = "abc") + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(SettingsIntent.AnalyzeClicked) + val analysis = expectMostRecentItem().analysis + assertThat(analysis).isNotNull() + assertThat(analysis!!.totalPasswords).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + + vm.effects.test { + vm.onIntent(SettingsIntent.AnalysisItemClicked(previewId)) + assertThat(awaitItem()) + .isEqualTo(SettingsEffect.Navigate(NavRoute.Details(previewId))) + } + assertThat(vm.state.value.analysis).isNull() + } + + @Test + fun `the recovery change runs in two steps and reports failure`() = runTest(dispatcher) { + coEvery { keyManager.changeRecoveryPassword(any(), any()) } returns false + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(SettingsIntent.ChangeRecoveryPasswordClicked) + assertThat(expectMostRecentItem().recoveryChange) + .isEqualTo(RecoveryChangeStep.CURRENT_PASSWORD) + + vm.onIntent(SettingsIntent.CurrentRecoveryPasswordEntered("old".toCharArray())) + assertThat(expectMostRecentItem().recoveryChange) + .isEqualTo(RecoveryChangeStep.NEW_PASSWORD) + cancelAndIgnoreRemainingEvents() + } + + vm.effects.test { + vm.onIntent(SettingsIntent.NewRecoveryPasswordEntered("new".toCharArray())) + assertThat(awaitItem()).isEqualTo( + SettingsEffect.ShowSnackbar( + UiText.StringResource(R.string.recovery_change_failed) + ) + ) + } + assertThat(vm.state.value.recoveryChange).isNull() + } + + @Test + fun `the entered passwords are zeroed rather than left in memory`() = runTest(dispatcher) { + coEvery { keyManager.changeRecoveryPassword(any(), any()) } returns true + val vm = viewModel() + val current = "old-secret".toCharArray() + val new = "new-secret".toCharArray() + + vm.effects.test { + vm.onIntent(SettingsIntent.CurrentRecoveryPasswordEntered(current)) + vm.onIntent(SettingsIntent.NewRecoveryPasswordEntered(new)) + awaitItem() + } + + assertThat(current.concatToString()).isEqualTo("\u0000".repeat(current.size)) + assertThat(new.concatToString()).isEqualTo("\u0000".repeat(new.size)) + } + + @Test + fun `abandoning the recovery flow wipes the password it was holding`() = runTest(dispatcher) { + val vm = viewModel() + val current = "old-secret".toCharArray() + + vm.onIntent(SettingsIntent.CurrentRecoveryPasswordEntered(current)) + vm.onIntent(SettingsIntent.RecoveryChangeDismissed) + + assertThat(current.concatToString()).isEqualTo("\u0000".repeat(current.size)) + assertThat(vm.state.value.recoveryChange).isNull() + } + + @Test + fun `choosing an auto-lock timeout persists it and closes the dialog`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(SettingsIntent.AutoLockClicked) + assertThat(expectMostRecentItem().isAutoLockDialogOpen).isTrue() + + vm.onIntent(SettingsIntent.AutoLockTimeoutSelected(AutoLockTimeout.MINUTES_5)) + val state = expectMostRecentItem() + assertThat(state.isAutoLockDialogOpen).isFalse() + assertThat(state.autoLockTimeout).isEqualTo(AutoLockTimeout.MINUTES_5) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `rating the app is an effect, not an intent started from the ViewModel`() = + runTest(dispatcher) { + // Opening the store needs a visible Activity; doing it here used to mean an + // Application context, FLAG_ACTIVITY_NEW_TASK and an unhandled crash with no Play. + val vm = viewModel() + vm.effects.test { + vm.onIntent(SettingsIntent.RateAppClicked) + assertThat(awaitItem()).isEqualTo(SettingsEffect.OpenStoreListing) + } + } + + @Test + fun `each import failure maps to its own message`() = runTest(dispatcher) { + val uri = mockk() + val vm = viewModel() + + val cases = mapOf( + BackupError.WrongPasswordOrCorrupt to R.string.import_wrong_password, + BackupError.FileTooLarge to R.string.import_file_too_large, + BackupError.UnsupportedVersion("v9") to R.string.import_unsupported_version, + BackupError.ReadFailed to R.string.import_failed + ) + + vm.effects.test { + cases.forEach { (error, expected) -> + coEvery { backupRepository.import(any(), any(), any()) } returns + Result.failure(BackupException(error)) + vm.onIntent( + SettingsIntent.ImportFileChosen(uri, "pw".toCharArray(), ImportMode.MERGE) + ) + assertThat(awaitItem()) + .isEqualTo(SettingsEffect.ShowSnackbar(UiText.StringResource(expected))) + } + } + } +} + +/** DataStore-free stand-in; the ViewModel only needs the values, not the storage. */ +private class FakePreferencesRepository : PreferencesRepository { + private val onboarding = MutableStateFlow(false) + private val languageTag = MutableStateFlow(null) + private val timeout = MutableStateFlow(AutoLockTimeout.DEFAULT) + + override val onboardingCompleted: Flow = onboarding + override suspend fun setOnboardingCompleted(completed: Boolean) { + onboarding.value = completed + } + + override val selectedLanguageTag: Flow = languageTag + override suspend fun setSelectedLanguageTag(tag: String) { + languageTag.value = tag + } + + override val autoLockTimeout: Flow = timeout + override suspend fun setAutoLockTimeout(timeout: AutoLockTimeout) { + this.timeout.value = timeout + } +} From e4a2fdda6c8fa36be0fd09c570f2c6cc5d299e7f Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:04:31 +0530 Subject: [PATCH 19/25] Give splash and onboarding their own effects and delete the global UiEvents Both screens are genuinely stateless - one intent, one navigation effect - so they get a contract with an intent and an effect and no state type. An empty XState would be ceremony, not architecture. What this does buy is the last use of the cross-feature UiEvents, which carried ShowSnackBar, CopyToClipboard and PopBackStack cases that neither screen can ever emit. Every call site had to close its `when` with an `else -> Unit`, and two of those branches survived on the preview and detail screens after those converted to their own sealed effects - so a new effect case could be added there today and silently do nothing. With UiEvents gone each `when` is exhaustive over its own type and the compiler enforces it. Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/navigation/NavGraph.kt | 4 +-- .../screens/detail_screen/DetailPage.kt | 4 --- .../onboarding_screens/OnBoardingContract.kt | 12 ++++++++ .../onboarding_screens/OnBoardingEvents.kt | 5 ---- .../onboarding_screens/OnBoardingPage.kt | 18 ++++++------ .../onboarding_screens/OnBoardingViewModel.kt | 14 ++++------ .../screens/preview_screen/PreviewPage.kt | 4 --- .../screens/splash_screen/SplashContract.kt | 15 ++++++++++ .../screens/splash_screen/SplashEvents.kt | 5 ---- .../screens/splash_screen/SplashPage.kt | 16 +++++------ .../screens/splash_screen/SplashViewModel.kt | 16 +++++------ .../com/bhardwaj/passkey/utils/UiEvents.kt | 28 ------------------- 12 files changed, 57 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingContract.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashContract.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt index ce607da..1081508 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/navigation/NavGraph.kt @@ -19,11 +19,11 @@ fun NavGraph(navController: NavHostController) { NavHost(navController = navController, startDestination = NavRoute.Splash) { composable { - SplashPage(onNavigate = { navController.replaceWith(it.route) }) + SplashPage(onNavigate = { navController.replaceWith(it) }) } composable { - OnBoardingScreen(onNavigate = { navController.replaceWith(it.route) }) + OnBoardingScreen(onNavigate = { navController.replaceWith(it) }) } composable { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 00186b4..1433fba 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -60,7 +60,6 @@ import com.bhardwaj.passkey.utils.ButtonType import com.bhardwaj.passkey.utils.asString import com.bhardwaj.passkey.utils.SecureClipboard import com.bhardwaj.passkey.utils.UiText -import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -114,9 +113,6 @@ fun DetailScreen( } } } - - - else -> Unit } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingContract.kt new file mode 100644 index 0000000..47a83aa --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingContract.kt @@ -0,0 +1,12 @@ +package com.bhardwaj.passkey.presentation.screens.onboarding_screens + +import com.bhardwaj.passkey.presentation.navigation.NavRoute + +/** Like the splash screen, this one carries no state of its own - the pager owns its page. */ +sealed interface OnBoardingIntent { + data object Finished : OnBoardingIntent +} + +sealed interface OnBoardingEffect { + data class Navigate(val route: NavRoute) : OnBoardingEffect +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt deleted file mode 100644 index e3632fd..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingEvents.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.bhardwaj.passkey.presentation.screens.onboarding_screens - -sealed interface OnBoardingEvents { - data object OnBoardingComplete : OnBoardingEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt index c20be22..caa1424 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingPage.kt @@ -18,11 +18,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingEvents import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingViewModel import com.bhardwaj.passkey.presentation.screens.onboarding_screens.components.OnBoardingItem import com.bhardwaj.passkey.presentation.screens.onboarding_screens.components.OnBoardingPageIndicator -import com.bhardwaj.passkey.utils.UiEvents +import com.bhardwaj.passkey.presentation.navigation.NavRoute import kotlinx.coroutines.launch data class OnBoardingScreen( @@ -32,7 +31,7 @@ data class OnBoardingScreen( @Composable fun OnBoardingScreen( - onNavigate: (UiEvents.Navigate) -> Unit, + onNavigate: (NavRoute) -> Unit, viewModel: OnBoardingViewModel = hiltViewModel() ) { // The emphasised word is marked with [[ ]] inside each heading string resource, so each @@ -52,11 +51,10 @@ fun OnBoardingScreen( ) ) - ObserveAsEvents(viewModel.uiEvents) { event -> - when (event) { - is UiEvents.Navigate -> onNavigate(event) - else -> Unit - } + ObserveAsEvents(viewModel.effects) { effect -> + when (effect) { + is OnBoardingEffect.Navigate -> onNavigate(effect.route) + } } Column( modifier = Modifier @@ -79,11 +77,11 @@ fun OnBoardingScreen( currentPage = pagerState.currentPage, totalPages = pages.size, onSkipClick = { - viewModel.onEvent(OnBoardingEvents.OnBoardingComplete) + viewModel.onIntent(OnBoardingIntent.Finished) }, onNextClick = { index -> if (index == pagerState.pageCount) { - viewModel.onEvent(OnBoardingEvents.OnBoardingComplete) + viewModel.onIntent(OnBoardingIntent.Finished) } else { scope.launch { pagerState.animateScrollToPage(index) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt index 6400208..66d2ecf 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/onboarding_screens/OnBoardingViewModel.kt @@ -4,8 +4,6 @@ import com.bhardwaj.passkey.presentation.navigation.NavRoute import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.domain.repository.PreferencesRepository -import com.bhardwaj.passkey.presentation.screens.onboarding_screens.OnBoardingEvents -import com.bhardwaj.passkey.utils.UiEvents import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -20,15 +18,15 @@ class OnBoardingViewModel @Inject constructor( // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded // screen has no active collector, and a rendezvous channel would suspend the coroutine // that emitted the effect until the user came back. - private val _uiEvents = Channel(Channel.BUFFERED) - val uiEvents = _uiEvents.receiveAsFlow() + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() - fun onEvent(event: OnBoardingEvents) { - when (event) { - is OnBoardingEvents.OnBoardingComplete -> { + fun onIntent(intent: OnBoardingIntent) { + when (intent) { + OnBoardingIntent.Finished -> { viewModelScope.launch { preferences.setOnboardingCompleted(true) - _uiEvents.send(UiEvents.Navigate(NavRoute.Security)) + _effects.send(OnBoardingEffect.Navigate(NavRoute.Security)) } } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt index c753453..315c0ef 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/PreviewPage.kt @@ -74,7 +74,6 @@ import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.utils.asString import com.bhardwaj.passkey.utils.SecureClipboard import com.bhardwaj.passkey.utils.UiText -import com.bhardwaj.passkey.utils.UiEvents import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -134,9 +133,6 @@ fun PreviewScreen( } } } - - - else -> Unit } } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashContract.kt new file mode 100644 index 0000000..e01da90 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashContract.kt @@ -0,0 +1,15 @@ +package com.bhardwaj.passkey.presentation.screens.splash_screen + +import com.bhardwaj.passkey.presentation.navigation.NavRoute + +/** + * No state type: the splash screen holds none, and an empty one would be ceremony. What it does + * have is one intent and one effect, which is the whole screen. + */ +sealed interface SplashIntent { + data object LoadingFinished : SplashIntent +} + +sealed interface SplashEffect { + data class Navigate(val route: NavRoute) : SplashEffect +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt deleted file mode 100644 index 8af7cd2..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashEvents.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.bhardwaj.passkey.presentation.screens.splash_screen - -sealed interface SplashEvents { - data object OnLoadingComplete : SplashEvents -} \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt index 8964813..c16150a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashPage.kt @@ -30,26 +30,24 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bhardwaj.passkey.presentation.screens.common.ObserveAsEvents import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashEvents import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashViewModel import com.bhardwaj.passkey.presentation.theme.Poppins -import com.bhardwaj.passkey.utils.UiEvents +import com.bhardwaj.passkey.presentation.navigation.NavRoute import kotlinx.coroutines.delay @Composable fun SplashPage( - onNavigate: (UiEvents.Navigate) -> Unit, + onNavigate: (NavRoute) -> Unit, viewModel: SplashViewModel = hiltViewModel() ) { val scale = remember { Animatable(0.3F) } - ObserveAsEvents(viewModel.uiEvents) { event -> - when (event) { - is UiEvents.Navigate -> onNavigate(event) - else -> Unit - } + ObserveAsEvents(viewModel.effects) { effect -> + when (effect) { + is SplashEffect.Navigate -> onNavigate(effect.route) + } } LaunchedEffect(key1 = true) { @@ -63,7 +61,7 @@ fun SplashPage( ) ) delay(700) - viewModel.onEvent(SplashEvents.OnLoadingComplete) + viewModel.onIntent(SplashIntent.LoadingFinished) } Scaffold { innerPadding -> diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt index b631d9f..891916d 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/splash_screen/SplashViewModel.kt @@ -8,8 +8,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bhardwaj.passkey.domain.repository.PreferencesRepository import kotlinx.coroutines.flow.first -import com.bhardwaj.passkey.presentation.screens.splash_screen.SplashEvents -import com.bhardwaj.passkey.utils.UiEvents import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow @@ -24,19 +22,19 @@ class SplashViewModel @Inject constructor( // BUFFERED, not the RENDEZVOUS default: with lifecycle-aware collection a backgrounded // screen has no active collector, and a rendezvous channel would suspend the coroutine // that emitted the effect until the user came back. - private val _uiEvents = Channel(Channel.BUFFERED) - val uiEvents = _uiEvents.receiveAsFlow() + private val _effects = Channel(Channel.BUFFERED) + val effects = _effects.receiveAsFlow() - fun onEvent(event: SplashEvents) { - when (event) { - SplashEvents.OnLoadingComplete -> { + fun onIntent(intent: SplashIntent) { + when (intent) { + SplashIntent.LoadingFinished -> { viewModelScope.launch { // first(), not collect(): a DataStore flow never completes, so collecting // it re-sent a Navigate effect on every later preference change - including a // language switch. val completed = preferences.onboardingCompleted.first() - _uiEvents.send( - UiEvents.Navigate( + _effects.send( + SplashEffect.Navigate( if (completed) NavRoute.Security else NavRoute.Onboarding ) ) diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt b/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt deleted file mode 100644 index 1642786..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/utils/UiEvents.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.bhardwaj.passkey.utils - -import com.bhardwaj.passkey.presentation.navigation.NavRoute - -sealed interface UiEvents { - data object PopBackStack : UiEvents - - data class Navigate(val route: NavRoute) : UiEvents - - /** - * Carries [UiText] rather than a resolved String so the message is localized at the call - * site, using the Activity context that actually honours the per-app locale. - */ - data class ShowSnackBar( - val message: UiText, - val action: UiText? = null - ) : UiEvents - - /** - * Copying is a UI concern and needs an Activity context to satisfy Android 10+'s rule that - * only a focused app may write the clipboard, so it is an effect rather than something the - * ViewModel does itself. - */ - data class CopyToClipboard( - val value: String, - val isSensitive: Boolean - ) : UiEvents -} From cda53b959a7c0ce6c2f8d6e7ec88d68951f25436 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:13:02 +0530 Subject: [PATCH 20/25] Add the autofill service the onboarding screen has always promised The third onboarding screen has said "Don't Type, Autofill Your Credentials" since the app shipped and no AutofillService existed anywhere in the code. This is that feature. The vault is encrypted and usually locked, so a fill request has two outcomes. Open: datasets built from entries whose heading names the same service as the requesting package or web domain. Locked: a single authentication entry that opens AutofillUnlockActivity and comes back with the datasets - deliberately carrying no entry names, since the suggestion list is drawn over another app's window and a locked vault must not leak which accounts it holds. The traversal of the assist structure is the only Android-bound part. Everything that is a decision - which entries match a package or domain, and which of their values are the username and the password - lives in AutofillMatcher, has no Android types, and has 11 unit tests. Two of those decisions are worth naming: a name is reduced to its identifying token, so com.google.android.gm and accounts.google.com both find a Google entry; and the username is the first non-secret value rather than a keyword match, because matching words like "user" or "email" is exactly what made the password analyser report a perfect vault in 16 locales. Saving works only while the vault is open - writing needs the key and a save request cannot authenticate - and says so rather than dropping the credential silently. A saved password is marked isSecret, so the analyser never has to guess for it. Also deletes ExampleUnitTest and ExampleInstrumentedTest, the IDE templates Phase 8 lists as dead. Co-Authored-By: Claude Opus 5 (1M context) --- .../passkey/ExampleInstrumentedTest.kt | 22 --- app/src/main/AndroidManifest.xml | 28 +++ .../data/autofill/AutofillResponseBuilder.kt | 100 ++++++++++ .../data/autofill/AutofillStructureParser.kt | 145 +++++++++++++++ .../domain/autofill/AutofillMatcher.kt | 102 ++++++++++ .../autofill/AutofillUnlockActivity.kt | 174 ++++++++++++++++++ .../autofill/PasskeyAutofillService.kt | 169 +++++++++++++++++ .../settings_screen/SettingsContract.kt | 4 + .../screens/settings_screen/SettingsPage.kt | 25 ++- .../settings_screen/SettingsViewModel.kt | 2 + app/src/main/res/layout/autofill_dataset.xml | 47 +++++ app/src/main/res/values-ar/strings.xml | 6 + app/src/main/res/values-bn/strings.xml | 6 + app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-gu/strings.xml | 6 + app/src/main/res/values-hi/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-ja/strings.xml | 6 + app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-mr/strings.xml | 6 + app/src/main/res/values-pt/strings.xml | 6 + app/src/main/res/values-ru/strings.xml | 6 + app/src/main/res/values-ta/strings.xml | 6 + app/src/main/res/values-te/strings.xml | 6 + app/src/main/res/values-zh/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/autofill_service.xml | 7 + .../bhardwaj/passkey/AutofillMatcherTest.kt | 130 +++++++++++++ .../com/bhardwaj/passkey/ExampleUnitTest.kt | 16 -- 31 files changed, 1034 insertions(+), 39 deletions(-) delete mode 100644 app/src/androidTest/java/com/bhardwaj/passkey/ExampleInstrumentedTest.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/autofill/AutofillResponseBuilder.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/autofill/AutofillStructureParser.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/autofill/AutofillMatcher.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/autofill/AutofillUnlockActivity.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/autofill/PasskeyAutofillService.kt create mode 100644 app/src/main/res/layout/autofill_dataset.xml create mode 100644 app/src/main/res/xml/autofill_service.xml create mode 100644 app/src/test/java/com/bhardwaj/passkey/AutofillMatcherTest.kt delete mode 100644 app/src/test/java/com/bhardwaj/passkey/ExampleUnitTest.kt diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/bhardwaj/passkey/ExampleInstrumentedTest.kt deleted file mode 100644 index 45261d9..0000000 --- a/app/src/androidTest/java/com/bhardwaj/passkey/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.bhardwaj.passkey - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.bhardwaj.passkey", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5ca115a..bdb6bf2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,34 @@ + + + + + + + + + + + + { + val tokens = AutofillMatcher.tokensOf(packageName, fields.webDomain) + if (tokens.isEmpty()) return emptyList() + val previews = repository.getPreviews().first() + val details = repository.getDetails().first().groupBy { it.previewId } + return AutofillMatcher.match(previews, details, tokens, secretKeywords.keywords()) + } + + fun fillResponse(fields: AutofillFields, credentials: List): FillResponse? { + if (credentials.isEmpty()) return null + val builder = FillResponse.Builder() + credentials.forEach { builder.addDataset(dataset(fields, it)) } + addSaveInfo(builder, fields) + return builder.build() + } + + /** + * What a locked vault offers: a single entry that authenticates instead of filling. + * + * No entry names appear here. The suggestion list is drawn over another app's window, so a + * locked vault must not leak which accounts it holds. + */ + fun authenticationResponse(fields: AutofillFields, sender: IntentSender): FillResponse { + val presentation = presentation( + label = context.getString(R.string.autofill_unlock), + sublabel = context.getString(R.string.app_name) + ) + return FillResponse.Builder() + .setAuthentication(fields.allIds(), sender, presentation) + .build() + } + + @Suppress("DEPRECATION") // Dataset.Builder(Presentations) is API 33+; minSdk here is 28. + private fun dataset(fields: AutofillFields, credential: AutofillCredential): Dataset { + val builder = Dataset.Builder( + presentation(credential.label, credential.username.orEmpty()) + ) + val username = credential.username + if (username != null) { + fields.usernameIds.forEach { builder.setValue(it, AutofillValue.forText(username)) } + } + fields.passwordIds.forEach { + builder.setValue(it, AutofillValue.forText(credential.password)) + } + return builder.build() + } + + /** + * Offers to save only when both halves of a credential are present. A save prompt on a + * password-only form would create an entry with nothing to identify it by. + */ + private fun addSaveInfo(builder: FillResponse.Builder, fields: AutofillFields) { + if (fields.usernameIds.isEmpty() || fields.passwordIds.isEmpty()) return + builder.setSaveInfo( + SaveInfo.Builder( + SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD, + fields.allIds() + ).build() + ) + } + + private fun presentation(label: String, sublabel: String): RemoteViews = + RemoteViews(context.packageName, R.layout.autofill_dataset).apply { + setTextViewText(R.id.autofill_label, label) + setTextViewText(R.id.autofill_sublabel, sublabel) + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/autofill/AutofillStructureParser.kt b/app/src/main/java/com/bhardwaj/passkey/data/autofill/AutofillStructureParser.kt new file mode 100644 index 0000000..8233c7a --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/autofill/AutofillStructureParser.kt @@ -0,0 +1,145 @@ +package com.bhardwaj.passkey.data.autofill + +import android.app.assist.AssistStructure +import android.text.InputType +import android.view.View +import android.view.autofill.AutofillId + +/** + * The fields of one form that this app can fill or save. + * + * Only the traversal lives here. Which entries match, and which of their values are the username + * and the password, is decided by AutofillMatcher, which has no Android types and is tested. + */ +data class AutofillFields( + val usernameIds: List = emptyList(), + val passwordIds: List = emptyList(), + val webDomain: String? = null +) { + /** A password field alone is fillable; a username field alone is not worth offering. */ + val isFillable: Boolean get() = passwordIds.isNotEmpty() + + fun allIds(): Array = (usernameIds + passwordIds).toTypedArray() +} + +object AutofillStructureParser { + + // Matched against view ids and HTML attribute names, which stay English even in a fully + // localized app - unlike the user-visible labels the password analyser once matched on. + private val PASSWORD_HINTS = listOf("password", "passwd", "pwd", "pass") + private val USERNAME_HINTS = listOf("username", "user", "email", "login", "account", "phone") + + fun parse(structure: AssistStructure): AutofillFields { + val usernames = mutableListOf() + val passwords = mutableListOf() + var domain: String? = null + + for (i in 0 until structure.windowNodeCount) { + fun visit(node: AssistStructure.ViewNode) { + node.webDomain?.takeIf { it.isNotBlank() }?.let { if (domain == null) domain = it } + val id = node.autofillId + if (id != null && node.autofillType == View.AUTOFILL_TYPE_TEXT) { + when (classify(node)) { + FieldKind.PASSWORD -> passwords += id + FieldKind.USERNAME -> usernames += id + FieldKind.UNKNOWN -> Unit + } + } + for (child in 0 until node.childCount) visit(node.getChildAt(child)) + } + visit(structure.getWindowNodeAt(i).rootViewNode) + } + + return AutofillFields( + usernameIds = usernames, + passwordIds = passwords, + webDomain = domain + ) + } + + /** What the user typed into a form, read back when the framework offers to save it. */ + data class SavedCredential( + val username: String?, + val password: String, + val webDomain: String? + ) + + /** + * Reads the values out of a submitted form. + * + * A save request arrives after the user has already typed, so the fields carry values; the + * fill path deliberately ignores them. + */ + fun extractCredential(structure: AssistStructure): SavedCredential? { + var username: String? = null + var password: String? = null + var domain: String? = null + + for (i in 0 until structure.windowNodeCount) { + fun visit(node: AssistStructure.ViewNode) { + node.webDomain?.takeIf { it.isNotBlank() }?.let { if (domain == null) domain = it } + val text = node.autofillValue + ?.takeIf { it.isText } + ?.textValue + ?.toString() + ?.takeIf { it.isNotBlank() } + if (text != null && node.autofillType == View.AUTOFILL_TYPE_TEXT) { + when (classify(node)) { + FieldKind.PASSWORD -> if (password == null) password = text + FieldKind.USERNAME -> if (username == null) username = text + FieldKind.UNKNOWN -> Unit + } + } + for (child in 0 until node.childCount) visit(node.getChildAt(child)) + } + visit(structure.getWindowNodeAt(i).rootViewNode) + } + + val secret = password ?: return null + return SavedCredential(username = username, password = secret, webDomain = domain) + } + + private enum class FieldKind { USERNAME, PASSWORD, UNKNOWN } + + /** + * Declared hints first, then the keyboard type, then the view's own id. The order matters: + * an app that declares its hints correctly should never be second-guessed by a substring. + */ + private fun classify(node: AssistStructure.ViewNode): FieldKind { + node.autofillHints?.forEach { hint -> + when (hint) { + View.AUTOFILL_HINT_PASSWORD -> return FieldKind.PASSWORD + View.AUTOFILL_HINT_USERNAME, + View.AUTOFILL_HINT_EMAIL_ADDRESS -> return FieldKind.USERNAME + } + // Compose and several libraries emit lowercase or vendor-prefixed hints. + val normalized = hint.lowercase() + if (PASSWORD_HINTS.any { normalized.contains(it) }) return FieldKind.PASSWORD + if (USERNAME_HINTS.any { normalized.contains(it) }) return FieldKind.USERNAME + } + + val variation = node.inputType and InputType.TYPE_MASK_VARIATION + val klass = node.inputType and InputType.TYPE_MASK_CLASS + if (klass == InputType.TYPE_CLASS_TEXT) { + when (variation) { + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD -> return FieldKind.PASSWORD + + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS -> return FieldKind.USERNAME + } + } + + val text = listOfNotNull( + node.idEntry, + node.hint, + node.htmlInfo?.attributes?.firstOrNull { it.first == "name" }?.second + ).joinToString(" ").lowercase() + if (text.isBlank()) return FieldKind.UNKNOWN + // Password is checked first: "user_password" is a password field, not a username one. + if (PASSWORD_HINTS.any { text.contains(it) }) return FieldKind.PASSWORD + if (USERNAME_HINTS.any { text.contains(it) }) return FieldKind.USERNAME + return FieldKind.UNKNOWN + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/autofill/AutofillMatcher.kt b/app/src/main/java/com/bhardwaj/passkey/domain/autofill/AutofillMatcher.kt new file mode 100644 index 0000000..427647c --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/autofill/AutofillMatcher.kt @@ -0,0 +1,102 @@ +package com.bhardwaj.passkey.domain.autofill + +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview + +/** One vault entry, reduced to what an autofill dataset can put in a form. */ +data class AutofillCredential( + val previewId: Long, + val label: String, + val username: String?, + val password: String +) + +/** + * Decides which vault entries belong to the app or web page being filled. + * + * Kept free of Android types on purpose: the AssistStructure walk is untestable on the JVM, so + * everything that can be a decision rather than a traversal lives here, where it can be. + */ +object AutofillMatcher { + + private const val MIN_TOKEN_LENGTH = 3 + + /** + * Parts of a package name or domain that identify nobody. "com.google.android.gm" and + * "accounts.google.com" both have to reduce to "google" for either to find a Gmail entry. + */ + private val NOISE = setOf( + "com", "org", "net", "io", "co", "uk", "in", "de", "app", "apps", "android", + "www", "mobile", "web", "login", "signin", "sign", "auth", "account", "accounts", + "secure", "my", "id", "www2", "m" + ) + + fun tokensOf(packageName: String?, webDomain: String?): Set { + val tokens = mutableSetOf() + webDomain?.let { domain -> + val host = domain.substringAfter("://").substringBefore('/').lowercase() + // The whole host, so an entry literally headed "mail.proton.me" still matches. + if (host.isNotBlank()) tokens += host + tokens += host.split('.') + } + packageName?.let { tokens += it.lowercase().split('.') } + return tokens + .map { it.normalize() } + .filter { it.length >= MIN_TOKEN_LENGTH && it !in NOISE } + .toSet() + } + + /** + * Entries whose heading names the same service. Matching runs both ways because a heading is + * free text: "Google" matches the token "google", and the heading "Gmail Work Account" + * contains it. + */ + fun match( + previews: List, + detailsByPreview: Map>, + tokens: Set, + secretKeywords: Set = emptySet() + ): List { + if (tokens.isEmpty()) return emptyList() + return previews + .filter { preview -> matches(preview.heading, tokens) } + .mapNotNull { preview -> + credentialFor(preview, detailsByPreview[preview.id].orEmpty(), secretKeywords) + } + } + + private fun matches(heading: String, tokens: Set): Boolean { + val normalized = heading.normalize() + if (normalized.length < MIN_TOKEN_LENGTH) return false + return tokens.any { token -> normalized.contains(token) || token.contains(normalized) } + } + + /** + * The password is the entry's secret; the username is whatever non-secret value comes first. + * + * Picking the username by elimination rather than by matching words like "user" or "email" + * is deliberate - that kind of keyword match is what made the password analyser return + * "perfect vault" in all 16 non-English locales. + */ + fun credentialFor( + preview: Preview, + details: List, + secretKeywords: Set = emptySet() + ): AutofillCredential? { + val ordered = details.sortedBy { it.sequence } + val password = ordered.firstOrNull { it.isSecret } + ?: ordered.firstOrNull { detail -> secretKeywords.any { detail.question.normalize().contains(it.normalize()) } } + ?: return null + if (password.answer.isBlank()) return null + val username = ordered.firstOrNull { it.id != password.id && it.answer.isNotBlank() } + return AutofillCredential( + previewId = preview.id, + label = preview.heading, + username = username?.answer, + password = password.answer + ) + } + + private fun String.normalize(): String = + lowercase().filter { it.isLetterOrDigit() } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/autofill/AutofillUnlockActivity.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/autofill/AutofillUnlockActivity.kt new file mode 100644 index 0000000..3f5dfc3 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/autofill/AutofillUnlockActivity.kt @@ -0,0 +1,174 @@ +package com.bhardwaj.passkey.presentation.autofill + +import android.app.assist.AssistStructure +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import android.view.autofill.AutofillManager +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.IntentCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.data.autofill.AutofillResponseBuilder +import com.bhardwaj.passkey.data.autofill.AutofillStructureParser +import com.bhardwaj.passkey.presentation.screens.security_screen.VaultGateViewModel +import com.bhardwaj.passkey.presentation.theme.PassKeyTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Unlocks the vault on behalf of an autofill request, then answers it. + * + * The framework launches this from the single "Unlock" suggestion the service offers while the + * vault is closed, and hands it the same assist structure the fill request carried. Once the + * user authenticates, the datasets are built here and returned as the authentication result, so + * the form is filled without a trip through the app. + * + * Paths the vault gate handles but this screen cannot - first-run provisioning, the one-time + * re-key, recovery - send the user to the app instead. None of them should happen for the first + * time inside another app's login form. + */ +@AndroidEntryPoint +class AutofillUnlockActivity : FragmentActivity() { + + @Inject + lateinit var responseBuilder: AutofillResponseBuilder + + private val viewModel: VaultGateViewModel by viewModels() + + private var structure: AssistStructure? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + + structure = IntentCompat.getParcelableExtra( + intent, + AutofillManager.EXTRA_ASSIST_STRUCTURE, + AssistStructure::class.java + ) + if (structure == null) { + // Nothing to fill; cancelling leaves the user's own keyboard input untouched. + setResult(RESULT_CANCELED) + finish() + return + } + + setContent { + PassKeyTheme { + val state by viewModel.state.collectAsStateWithLifecycle() + UnlockContent( + state = state, + onAuthenticate = ::requestUnlock, + onCancel = { + setResult(RESULT_CANCELED) + finish() + }, + onUnlocked = ::respondWithDatasets + ) + } + } + viewModel.start() + } + + private fun requestUnlock() { + viewModel.onUnlockRequested( + activity = this, + title = getString(R.string.app_name), + subtitle = getString(R.string.autofill_unlock), + negativeButton = getString(R.string.cancel) + ) + } + + private fun respondWithDatasets() { + val assist = structure ?: return + lifecycleScope.launch { + val fields = AutofillStructureParser.parse(assist) + val credentials = responseBuilder.credentialsFor( + fields, + assist.activityComponent?.packageName + ) + val response = responseBuilder.fillResponse(fields, credentials) + setResult( + RESULT_OK, + Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, response) + ) + finish() + } + } +} + +@Composable +private fun UnlockContent( + state: VaultGateViewModel.State, + onAuthenticate: () -> Unit, + onCancel: () -> Unit, + onUnlocked: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (state) { + VaultGateViewModel.State.Locked -> { + // Prompt immediately: the user already chose to fill, so a second tap to + // begin authenticating would be a step for nothing. + LaunchedEffect(Unit) { onAuthenticate() } + CircularProgressIndicator() + } + + VaultGateViewModel.State.Checking, + VaultGateViewModel.State.Authenticating, + VaultGateViewModel.State.Migrating -> CircularProgressIndicator() + + VaultGateViewModel.State.Unlocked -> + LaunchedEffect(Unit) { onUnlocked() } + + else -> { + Text( + text = stringResource(R.string.autofill_open_app), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground + ) + Button( + modifier = Modifier.padding(top = 16.dp), + onClick = onCancel + ) { + Text(text = stringResource(R.string.cancel)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/autofill/PasskeyAutofillService.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/autofill/PasskeyAutofillService.kt new file mode 100644 index 0000000..08812dd --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/autofill/PasskeyAutofillService.kt @@ -0,0 +1,169 @@ +package com.bhardwaj.passkey.presentation.autofill + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.data.autofill.AutofillResponseBuilder +import com.bhardwaj.passkey.data.autofill.AutofillStructureParser +import com.bhardwaj.passkey.data.local.VaultDatabaseProvider +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Fills credentials into other apps and web pages. + * + * The third onboarding screen has advertised "Don't Type, Autofill Your Credentials" since the + * app shipped, and no AutofillService existed. This is that promise. + * + * The vault is encrypted and usually locked, so a fill request has two outcomes: if the vault is + * open, real datasets; if not, one authentication entry that opens [AutofillUnlockActivity] and + * comes back with the datasets. Nothing about the vault's contents - not even how many entries + * match - is visible before the user authenticates. + */ +@AndroidEntryPoint +class PasskeyAutofillService : AutofillService() { + + @Inject + lateinit var vault: VaultDatabaseProvider + + @Inject + lateinit var responseBuilder: AutofillResponseBuilder + + @Inject + lateinit var repository: PasskeyRepository + + private val job = SupervisorJob() + private val scope = CoroutineScope(job + Dispatchers.Default) + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + val context = request.fillContexts.lastOrNull() + if (context == null) { + callback.onSuccess(null) + return + } + val structure = context.structure + val fields = AutofillStructureParser.parse(structure) + if (!fields.isFillable) { + callback.onSuccess(null) + return + } + + if (!vault.isOpen) { + callback.onSuccess( + responseBuilder.authenticationResponse(fields, authenticationSender()) + ) + return + } + + val clientPackage = structure.activityComponent?.packageName + val work = scope.launch { + val credentials = responseBuilder.credentialsFor(fields, clientPackage) + callback.onSuccess(responseBuilder.fillResponse(fields, credentials)) + } + cancellationSignal.setOnCancelListener { work.cancel() } + } + + /** + * Saves a credential the user has just typed elsewhere. + * + * Only possible while the vault is open: writing needs the key, and there is no way to + * authenticate from inside a save request. A locked vault reports failure rather than + * silently dropping the credential, so the user is told nothing was saved. + */ + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + val structure = request.fillContexts.lastOrNull()?.structure + val credential = structure?.let { AutofillStructureParser.extractCredential(it) } + if (credential == null || credential.password.isBlank()) { + callback.onFailure(getString(R.string.autofill_save_failed)) + return + } + if (!vault.isOpen) { + callback.onFailure(getString(R.string.autofill_save_locked)) + return + } + + val heading = credential.heading(structure.activityComponent?.packageName) + scope.launch { + val saved = runCatching { + repository.runInTransaction { + val previewId = repository.getPreviewByHeading(heading, Category.OTHERS)?.id + ?: repository.createPreview(heading, Category.OTHERS) + credential.username?.takeIf { it.isNotBlank() }?.let { + repository.createDetail(previewId, USERNAME_LABEL, it, sequence = 0) + } + repository.createDetail( + previewId = previewId, + question = PASSWORD_LABEL, + answer = credential.password, + sequence = 1, + // Known to be a secret by where it came from, so the analyser never has + // to guess for it in any locale. + isSecret = true + ) + } + true + }.getOrDefault(false) + + if (saved) { + callback.onSuccess() + } else { + callback.onFailure(getString(R.string.autofill_save_failed)) + } + } + } + + private fun authenticationSender(): android.content.IntentSender { + // Mutable because the framework adds the assist structure to this intent before + // launching it. Below API 31 there is no flag to pass: PendingIntents were mutable by + // default, and FLAG_IMMUTABLE is what had to be asked for. + val mutable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + return PendingIntent.getActivity( + this, + AUTH_REQUEST_CODE, + Intent(this, AutofillUnlockActivity::class.java), + PendingIntent.FLAG_CANCEL_CURRENT or mutable + ).intentSender + } + + private companion object { + const val AUTH_REQUEST_CODE = 9001 + const val USERNAME_LABEL = "Username" + const val PASSWORD_LABEL = "Password" + } +} + +private fun AutofillStructureParser.SavedCredential.heading(clientPackage: String?): String { + // The web domain names the service; a package name is the fallback, tidied to its most + // distinctive part so the entry reads "instagram" rather than "com.instagram.android". + webDomain?.substringAfter("://")?.substringBefore('/')?.takeIf { it.isNotBlank() } + ?.let { return it } + val parts = clientPackage?.split('.').orEmpty() + return parts.getOrNull(1) ?: clientPackage ?: "Autofill" +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt index af176f6..21f992b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt @@ -44,6 +44,7 @@ sealed interface SettingsIntent { data object SheetDismissed : SettingsIntent data object RateAppClicked : SettingsIntent + data object AutofillClicked : SettingsIntent data class ExportFileChosen(val uri: Uri, val password: CharArray) : SettingsIntent { override fun equals(other: Any?) = this === other || @@ -104,4 +105,7 @@ sealed interface SettingsEffect { * handled the device having no Play Store, which threw ActivityNotFoundException. */ data object OpenStoreListing : SettingsEffect + + /** Android owns the autofill service picker; the app can only ask for it to be shown. */ + data object OpenAutofillSettings : SettingsEffect } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt index 7ebb48b..096b360 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsPage.kt @@ -1,9 +1,10 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.Settings +import android.view.autofill.AutofillManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -182,6 +183,25 @@ fun SettingsScreen( // Started from the Activity context, and guarded: a device without the Play Store // used to take an unhandled ActivityNotFoundException straight to a crash. + // The system's autofill picker. There is no API to enable a service directly, + // which is the right restriction for something that can read other apps' forms. + SettingsEffect.OpenAutofillSettings -> { + val manager = context.getSystemService(AutofillManager::class.java) + val opened = manager?.isAutofillSupported == true && runCatching { + context.startActivity( + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE) + .setData("package:${context.packageName}".toUri()) + ) + }.isSuccess + if (!opened) { + scope.launch { + snackBarHostState.showSnackbar( + UiText.StringResource(R.string.autofill_unsupported).asString(context) + ) + } + } + } + SettingsEffect.OpenStoreListing -> { val opened = runCatching { context.startActivity( @@ -274,6 +294,9 @@ fun SettingsScreen( SettingsText(text = stringResource(id = R.string.rate_app)) { onIntent(SettingsIntent.RateAppClicked) } + SettingsText(text = stringResource(id = R.string.autofill)) { + onIntent(SettingsIntent.AutofillClicked) + } SettingsText(text = stringResource(id = R.string.auto_lock)) { onIntent(SettingsIntent.AutoLockClicked) } diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index 8746499..4ad2176 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -104,6 +104,8 @@ class SettingsViewModel @Inject constructor( SettingsIntent.RateAppClicked -> emit(SettingsEffect.OpenStoreListing) + SettingsIntent.AutofillClicked -> emit(SettingsEffect.OpenAutofillSettings) + is SettingsIntent.ExportFileChosen -> viewModelScope.launch { val result = backupRepository.export(intent.uri, intent.password) Arrays.fill(intent.password, Char(0)) diff --git a/app/src/main/res/layout/autofill_dataset.xml b/app/src/main/res/layout/autofill_dataset.xml new file mode 100644 index 0000000..57dec53 --- /dev/null +++ b/app/src/main/res/layout/autofill_dataset.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3db0347..8f5bf02 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -166,4 +166,10 @@ صدّر نسخة احتياطية أولًا صدّر خزنتي الآن خدمة Google Play غير متوفرة على هذا الجهاز. + الملء التلقائي + افتح القفل للملء + افتح PassKey لإكمال إعداد خزنتك، ثم أعد المحاولة. + تعذر حفظ بيانات تسجيل الدخول. + افتح قفل PassKey لحفظ بيانات تسجيل الدخول. + الملء التلقائي غير متوفر على هذا الجهاز. \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 2b229d6..50e17c0 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -162,4 +162,10 @@ আগে ব্যাকআপ নিন এখনই আমার ভল্ট রপ্তানি করুন এই ডিভাইসে Google Play উপলব্ধ নেই। + স্বয়ংক্রিয় পূরণ + পূরণ করতে আনলক করুন + ভল্ট সেটআপ শেষ করতে PassKey খুলুন, তারপর আবার চেষ্টা করুন। + এই লগইন সংরক্ষণ করা যায়নি। + এই লগইন সংরক্ষণ করতে PassKey আনলক করুন। + এই ডিভাইসে স্বয়ংক্রিয় পূরণ উপলব্ধ নেই। diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9ef0682..e58d70c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -163,4 +163,10 @@ Zuerst ein Backup exportieren Tresor jetzt exportieren Google Play ist auf diesem Gerät nicht verfügbar. + Automatisches Ausfüllen + Zum Ausfüllen entsperren + Öffne PassKey, um die Einrichtung des Tresors abzuschließen, und versuche es erneut. + Diese Anmeldedaten konnten nicht gespeichert werden. + Entsperre PassKey, um diese Anmeldedaten zu speichern. + Automatisches Ausfüllen ist auf diesem Gerät nicht verfügbar. \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 86162b3..d23e0e2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -163,4 +163,10 @@ Exportar una copia primero Exportar mi caja fuerte ahora Google Play no está disponible en este dispositivo. + Autocompletar + Desbloquea para completar + Abre PassKey para terminar de configurar tu caja fuerte y vuelve a intentarlo. + No se pudo guardar este inicio de sesión. + Desbloquea PassKey para guardar este inicio de sesión. + Autocompletar no está disponible en este dispositivo. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c6afcf6..0976acb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -163,4 +163,10 @@ Exporter une sauvegarde d\'abord Exporter mon coffre maintenant Google Play n\'est pas disponible sur cet appareil. + Saisie automatique + Déverrouiller pour remplir + Ouvre PassKey pour terminer la configuration de ton coffre, puis réessaie. + Impossible d\'enregistrer ces identifiants. + Déverrouille PassKey pour enregistrer ces identifiants. + La saisie automatique n\'est pas disponible sur cet appareil. diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index 5641b15..f0a8593 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -161,4 +161,10 @@ પહેલાં બૅકઅપ લો હમણાં મારી વૉલ્ટ નિકાસ કરો આ ઉપકરણ પર Google Play ઉપલબ્ધ નથી. + ઑટોફિલ + ભરવા માટે અનલૉક કરો + તમારું વૉલ્ટ સેટઅપ પૂરું કરવા PassKey ખોલો, પછી ફરી પ્રયાસ કરો. + આ લૉગિન સાચવી શકાયું નહીં. + આ લૉગિન સાચવવા માટે PassKey અનલૉક કરો. + આ ઉપકરણ પર ઑટોફિલ ઉપલબ્ધ નથી. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index e8d5a7a..f94c7fb 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -162,4 +162,10 @@ पहले बैकअप निर्यात करें मेरी वॉल्ट अभी निर्यात करें इस डिवाइस पर Google Play उपलब्ध नहीं है। + ऑटोफ़िल + भरने के लिए अनलॉक करें + अपना वॉल्ट सेटअप पूरा करने के लिए PassKey खोलें, फिर दोबारा कोशिश करें। + यह लॉगिन सहेजा नहीं जा सका। + यह लॉगिन सहेजने के लिए PassKey अनलॉक करें। + इस डिवाइस पर ऑटोफ़िल उपलब्ध नहीं है। \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8aa4965..a30b59b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -163,4 +163,10 @@ Esporta prima un backup Esporta la mia cassaforte ora Google Play non è disponibile su questo dispositivo. + Compilazione automatica + Sblocca per compilare + Apri PassKey per completare la configurazione della cassaforte, poi riprova. + Impossibile salvare queste credenziali. + Sblocca PassKey per salvare queste credenziali. + La compilazione automatica non è disponibile su questo dispositivo. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e5ba4f1..5068828 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -160,4 +160,10 @@ 先にバックアップを書き出す 今すぐ保管庫を書き出す このデバイスでは Google Play を利用できません。 + 自動入力 + ロックを解除して入力 + PassKey を開いて保管庫の設定を完了してから、もう一度お試しください。 + このログイン情報を保存できませんでした。 + このログイン情報を保存するには PassKey のロックを解除してください。 + このデバイスでは自動入力を利用できません。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 52106e0..92c0909 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -161,4 +161,10 @@ 먼저 백업 내보내기 지금 보관함 내보내기 이 기기에서는 Google Play를 사용할 수 없습니다. + 자동 완성 + 잠금 해제 후 입력 + PassKey를 열어 보관함 설정을 완료한 후 다시 시도하세요. + 이 로그인 정보를 저장하지 못했습니다. + 이 로그인 정보를 저장하려면 PassKey의 잠금을 해제하세요. + 이 기기에서는 자동 완성을 사용할 수 없습니다. \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index e18ddb1..ad76945 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -162,4 +162,10 @@ आधी बॅकअप घ्या माझी व्हॉल्ट आता निर्यात करा या डिव्हाइसवर Google Play उपलब्ध नाही. + ऑटोफिल + भरण्यासाठी अनलॉक करा + तुमचे व्हॉल्ट सेटअप पूर्ण करण्यासाठी PassKey उघडा, नंतर पुन्हा प्रयत्न करा. + हे लॉगिन जतन करता आले नाही. + हे लॉगिन जतन करण्यासाठी PassKey अनलॉक करा. + या डिव्हाइसवर ऑटोफिल उपलब्ध नाही. \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9d79f23..02f4ec9 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -163,4 +163,10 @@ Exportar um backup antes Exportar meu cofre agora O Google Play não está disponível neste dispositivo. + Preenchimento automático + Desbloqueie para preencher + Abra o PassKey para concluir a configuração do cofre e tente novamente. + Não foi possível salvar este login. + Desbloqueie o PassKey para salvar este login. + O preenchimento automático não está disponível neste dispositivo. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 670dfb0..b119c46 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -163,4 +163,10 @@ Сначала выгрузить копию Выгрузить хранилище сейчас Google Play недоступен на этом устройстве. + Автозаполнение + Разблокируйте для заполнения + Откройте PassKey, чтобы завершить настройку хранилища, и повторите попытку. + Не удалось сохранить эти данные для входа. + Разблокируйте PassKey, чтобы сохранить эти данные для входа. + Автозаполнение недоступно на этом устройстве. diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index a3346bf..86d02a7 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -161,4 +161,10 @@ முதலில் காப்புப்பிரதி எடு என் பெட்டகத்தை இப்போது ஏற்றுமதி செய் இந்தச் சாதனத்தில் Google Play கிடைக்கவில்லை. + தானியங்கு நிரப்புதல் + நிரப்ப திறக்கவும் + உங்கள் பெட்டக அமைப்பை முடிக்க PassKey ஐத் திறந்து, மீண்டும் முயலவும். + இந்த உள்நுழைவைச் சேமிக்க முடியவில்லை. + இந்த உள்நுழைவைச் சேமிக்க PassKey ஐத் திறக்கவும். + இந்தச் சாதனத்தில் தானியங்கு நிரப்புதல் கிடைக்கவில்லை. diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index ec67e15..13e088e 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -161,5 +161,11 @@ ముందు బ్యాకప్ తీసుకోండి నా వాల్ట్‌ను ఇప్పుడే ఎగుమతి చేయి ఈ పరికరంలో Google Play అందుబాటులో లేదు. + ఆటోఫిల్ + నింపడానికి అన్‌లాక్ చేయండి + మీ వాల్ట్ సెటప్ పూర్తి చేయడానికి PassKey తెరిచి, మళ్లీ ప్రయత్నించండి. + ఈ లాగిన్‌ను సేవ్ చేయలేకపోయాం. + ఈ లాగిన్‌ను సేవ్ చేయడానికి PassKey అన్‌లాక్ చేయండి. + ఈ పరికరంలో ఆటోఫిల్ అందుబాటులో లేదు. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9375bc0..a6f5efd 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -161,4 +161,10 @@ 先导出备份 立即导出我的密码库 此设备上无法使用 Google Play。 + 自动填充 + 解锁以填充 + 请打开 PassKey 完成保险库设置,然后重试。 + 无法保存此登录信息。 + 请解锁 PassKey 以保存此登录信息。 + 此设备不支持自动填充。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a89479..0bdecae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,4 +236,10 @@ Questions: contact yrkkh.cclub@gm Export a backup first Export my vault now Google Play is not available on this device. + Autofill + Unlock to fill + Open PassKey to finish setting up your vault, then try again. + Could not save this login. + Unlock PassKey to save this login. + Autofill is not available on this device. \ No newline at end of file diff --git a/app/src/main/res/xml/autofill_service.xml b/app/src/main/res/xml/autofill_service.xml new file mode 100644 index 0000000..d339d21 --- /dev/null +++ b/app/src/main/res/xml/autofill_service.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/test/java/com/bhardwaj/passkey/AutofillMatcherTest.kt b/app/src/test/java/com/bhardwaj/passkey/AutofillMatcherTest.kt new file mode 100644 index 0000000..24a321c --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/AutofillMatcherTest.kt @@ -0,0 +1,130 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.domain.autofill.AutofillMatcher +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.Preview +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AutofillMatcherTest { + + private fun preview(id: Long, heading: String) = + Preview(id = id, heading = heading, category = Category.APPS) + + private fun detail( + id: Long, + previewId: Long, + question: String, + answer: String, + sequence: Long = 0, + isSecret: Boolean = false + ) = Detail(id, previewId, question, answer, sequence, isSecret) + + @Test + fun `a package name and a domain reduce to the same identifying token`() { + assertThat(AutofillMatcher.tokensOf("com.google.android.gm", null)).contains("google") + assertThat(AutofillMatcher.tokensOf(null, "accounts.google.com")).contains("google") + } + + @Test + fun `boilerplate parts of a name identify nobody and are dropped`() { + val tokens = AutofillMatcher.tokensOf("com.example.android.app", "www.login.example.com") + assertThat(tokens).containsExactly("example", "wwwloginexamplecom") + } + + @Test + fun `the full host survives, so an entry headed with one still matches`() { + val tokens = AutofillMatcher.tokensOf(null, "https://mail.proton.me/login") + val previews = listOf(preview(1, "mail.proton.me")) + val details = mapOf(1L to listOf(detail(10, 1, "Password", "pw", isSecret = true))) + + assertThat(AutofillMatcher.match(previews, details, tokens).map { it.label }) + .containsExactly("mail.proton.me") + } + + @Test + fun `no identifying token means no suggestions rather than every entry`() { + val previews = listOf(preview(1, "Gmail")) + val details = mapOf(1L to listOf(detail(10, 1, "Password", "pw", isSecret = true))) + + assertThat(AutofillMatcher.match(previews, details, emptySet())).isEmpty() + } + + @Test + fun `an entry with no secret is not offered`() { + // Filling a form with a note would be worse than offering nothing. + val previews = listOf(preview(1, "Google")) + val details = mapOf(1L to listOf(detail(10, 1, "Note", "some text"))) + + assertThat(AutofillMatcher.match(previews, details, setOf("google"))).isEmpty() + } + + @Test + fun `a secret with an empty value is not offered`() { + val previews = listOf(preview(1, "Google")) + val details = mapOf(1L to listOf(detail(10, 1, "Password", " ", isSecret = true))) + + assertThat(AutofillMatcher.match(previews, details, setOf("google"))).isEmpty() + } + + @Test + fun `the username is the first non-secret value, not a keyword match`() { + val previews = listOf(preview(1, "Google")) + val details = mapOf( + 1L to listOf( + detail(10, 1, "correo", "alice@example.com", sequence = 0), + detail(11, 1, "contraseña", "s3cret", sequence = 1, isSecret = true) + ) + ) + + val credential = AutofillMatcher.match(previews, details, setOf("google")).single() + assertThat(credential.username).isEqualTo("alice@example.com") + assertThat(credential.password).isEqualTo("s3cret") + } + + @Test + fun `an older entry with no isSecret flag falls back to keyword matching`() { + // Rows written before the isSecret column existed default to false. + val previews = listOf(preview(1, "Google")) + val details = mapOf( + 1L to listOf( + detail(10, 1, "Username", "alice", sequence = 0), + detail(11, 1, "Password", "s3cret", sequence = 1) + ) + ) + + val credential = AutofillMatcher + .match(previews, details, setOf("google"), secretKeywords = setOf("password")) + .single() + assertThat(credential.password).isEqualTo("s3cret") + assertThat(credential.username).isEqualTo("alice") + } + + @Test + fun `matching is case and punctuation insensitive in both directions`() { + val previews = listOf(preview(1, "G-Mail"), preview(2, "Gmail Work Account")) + val details = (1L..2L).associateWith { + listOf(detail(it * 10, it, "Password", "pw", isSecret = true)) + } + + assertThat(AutofillMatcher.match(previews, details, setOf("gmail")).map { it.previewId }) + .containsExactly(1L, 2L) + } + + @Test + fun `an unrelated entry is not offered`() { + val previews = listOf(preview(1, "Netflix")) + val details = mapOf(1L to listOf(detail(10, 1, "Password", "pw", isSecret = true))) + + assertThat(AutofillMatcher.match(previews, details, setOf("google"))).isEmpty() + } + + @Test + fun `a one or two letter heading cannot match everything`() { + val previews = listOf(preview(1, "X")) + val details = mapOf(1L to listOf(detail(10, 1, "Password", "pw", isSecret = true))) + + assertThat(AutofillMatcher.match(previews, details, setOf("example"))).isEmpty() + } +} diff --git a/app/src/test/java/com/bhardwaj/passkey/ExampleUnitTest.kt b/app/src/test/java/com/bhardwaj/passkey/ExampleUnitTest.kt deleted file mode 100644 index 9d0a935..0000000 --- a/app/src/test/java/com/bhardwaj/passkey/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.bhardwaj.passkey - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file From 5c423347953ac6a810d728d6348df71d68d4729f Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:20:59 +0530 Subject: [PATCH 21/25] Add authenticator codes and password history to the schema Room 3 to 4, one migration, three changes. details_table gains the index on previewId that the plan has listed since the audit: every lookup of an entry's details filters on it and each one was a full table scan. It does not gain the foreign key - that means rebuilding the table holding every password the user owns, and the cascade is already enforced in one transaction by the repository. The two new tables are new, so they carry real foreign keys with ON DELETE CASCADE from the start. The migration is additive only - one index and two CREATE TABLE IF NOT EXISTS - so an interrupted run has nothing half-done and a retry is a no-op. Its statements are copied verbatim from Room's exported 4.json, because they have to match it exactly or the identity check fails on the first open after upgrading and no install starts. Four instrumented tests cover it, including running the migration twice and the 1-to-4 chain a long-dormant user hits. TOTP itself is RFC 6238, and all six SHA-1 test vectors plus the SHA-256 and SHA-512 ones are asserted, along with a Base32 decoder deliberately tolerant of how secrets are actually printed - grouped, lowercase, unpadded - and an otpauth:// parser that rejects HOTP rather than generating codes that could never verify. Password history is written by the repository inside updateDetail rather than by its callers. There are three of them - the editor, import, and autofill's save - and a history that depends on each one remembering is a history with holes in it. Capped at ten versions per secret. The backup format carries both, and gains `isSecret`, which it should have carried all along: without it a restore reset every entry to keyword matching, which classifies nothing outside English. Payload schema 2, and the file format version goes to 3 alongside it so an older build reports "made by a newer version" rather than failing on an unknown key and blaming the user's password. Schema 1 files still restore. 103 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../4.json | 247 ++++++++++++++++++ .../com/bhardwaj/passkey/MigrationTester.kt | 78 ++++++ .../passkey/data/backup/BackupFormat.kt | 7 +- .../passkey/data/backup/BackupPayload.kt | 29 +- .../passkey/data/backup/BackupRepository.kt | 48 +++- .../passkey/data/local/PasskeyDatabase.kt | 15 +- .../data/local/VaultDatabaseProvider.kt | 3 +- .../data/local/dao/DetailHistoryDao.kt | 34 +++ .../passkey/data/local/dao/TotpDao.kt | 24 ++ .../data/local/entity/DetailHistoryEntity.kt | 38 +++ .../data/local/entity/DetailsEntity.kt | 11 +- .../passkey/data/local/entity/TotpEntity.kt | 44 ++++ .../passkey/data/mapper/VaultMappers.kt | 49 ++++ .../data/repository/PasskeyRepositoryImpl.kt | 64 ++++- .../passkey/domain/model/TotpEntry.kt | 25 ++ .../domain/repository/PasskeyRepository.kt | 18 ++ .../bhardwaj/passkey/domain/totp/OtpAuth.kt | 143 ++++++++++ .../com/bhardwaj/passkey/domain/totp/Totp.kt | 105 ++++++++ .../com/bhardwaj/passkey/utils/Constants.kt | 2 + .../bhardwaj/passkey/utils/Migration_3_4.kt | 61 +++++ .../com/bhardwaj/passkey/BackupPayloadTest.kt | 90 +++++++ .../bhardwaj/passkey/FakePasskeyRepository.kt | 41 +++ .../java/com/bhardwaj/passkey/TotpTest.kt | 151 +++++++++++ 23 files changed, 1315 insertions(+), 12 deletions(-) create mode 100644 app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/4.json create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailHistoryDao.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/local/dao/TotpDao.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailHistoryEntity.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/data/local/entity/TotpEntity.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/model/TotpEntry.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/totp/OtpAuth.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/totp/Totp.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/utils/Migration_3_4.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/BackupPayloadTest.kt create mode 100644 app/src/test/java/com/bhardwaj/passkey/TotpTest.kt diff --git a/app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/4.json b/app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/4.json new file mode 100644 index 0000000..a39368a --- /dev/null +++ b/app/schemas/com.bhardwaj.passkey.data.local.PasskeyDatabase/4.json @@ -0,0 +1,247 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "01a625472fb625a013b3a9b98c929b5e", + "entities": [ + { + "tableName": "preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`previewId` INTEGER PRIMARY KEY AUTOINCREMENT, `heading` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `sequence` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "previewId", + "columnName": "previewId", + "affinity": "INTEGER" + }, + { + "fieldPath": "heading", + "columnName": "heading", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryName", + "columnName": "categoryName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "previewId" + ] + } + }, + { + "tableName": "details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`detailsId` INTEGER PRIMARY KEY AUTOINCREMENT, `previewId` INTEGER NOT NULL, `question` TEXT NOT NULL, `answer` TEXT NOT NULL, `sequence` INTEGER NOT NULL, `isSecret` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "detailsId", + "columnName": "detailsId", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewId", + "columnName": "previewId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "question", + "columnName": "question", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answer", + "columnName": "answer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSecret", + "columnName": "isSecret", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "detailsId" + ] + }, + "indices": [ + { + "name": "index_details_table_previewId", + "unique": false, + "columnNames": [ + "previewId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_details_table_previewId` ON `${TABLE_NAME}` (`previewId`)" + } + ] + }, + { + "tableName": "totp_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`totpId` INTEGER PRIMARY KEY AUTOINCREMENT, `previewId` INTEGER NOT NULL, `label` TEXT NOT NULL, `secret` TEXT NOT NULL, `issuer` TEXT, `algorithm` TEXT NOT NULL, `digits` INTEGER NOT NULL, `periodSeconds` INTEGER NOT NULL, FOREIGN KEY(`previewId`) REFERENCES `preview_table`(`previewId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "totpId", + "columnName": "totpId", + "affinity": "INTEGER" + }, + { + "fieldPath": "previewId", + "columnName": "previewId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "issuer", + "columnName": "issuer", + "affinity": "TEXT" + }, + { + "fieldPath": "algorithm", + "columnName": "algorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "digits", + "columnName": "digits", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodSeconds", + "columnName": "periodSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "totpId" + ] + }, + "indices": [ + { + "name": "index_totp_table_previewId", + "unique": false, + "columnNames": [ + "previewId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_totp_table_previewId` ON `${TABLE_NAME}` (`previewId`)" + } + ], + "foreignKeys": [ + { + "table": "preview_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "previewId" + ], + "referencedColumns": [ + "previewId" + ] + } + ] + }, + { + "tableName": "detail_history_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`historyId` INTEGER PRIMARY KEY AUTOINCREMENT, `detailsId` INTEGER NOT NULL, `answer` TEXT NOT NULL, `changedAt` INTEGER NOT NULL, FOREIGN KEY(`detailsId`) REFERENCES `details_table`(`detailsId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "historyId", + "columnName": "historyId", + "affinity": "INTEGER" + }, + { + "fieldPath": "detailsId", + "columnName": "detailsId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "answer", + "columnName": "answer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changedAt", + "columnName": "changedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "historyId" + ] + }, + "indices": [ + { + "name": "index_detail_history_table_detailsId", + "unique": false, + "columnNames": [ + "detailsId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_detail_history_table_detailsId` ON `${TABLE_NAME}` (`detailsId`)" + } + ], + "foreignKeys": [ + { + "table": "details_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "detailsId" + ], + "referencedColumns": [ + "detailsId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '01a625472fb625a013b3a9b98c929b5e')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt b/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt index 93b7364..572544c 100644 --- a/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt +++ b/app/src/androidTest/java/com/bhardwaj/passkey/MigrationTester.kt @@ -9,6 +9,7 @@ import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_TABLE import com.bhardwaj.passkey.utils.MIGRATION_1_2 import com.bhardwaj.passkey.utils.MIGRATION_2_3 +import com.bhardwaj.passkey.utils.MIGRATION_3_4 import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -163,4 +164,81 @@ class MigrationTester { assertThat(getInt(getColumnIndex("isSecret"))).isEqualTo(0) } } + + @Test + fun migration_3_to_4_adds_the_new_tables_and_keeps_every_row() { + var db = helper.createDatabase(DB_NAME, 3) + + db.execSQL( + "INSERT INTO $PREVIEW_TABLE (heading, categoryName, sequence) " + + "VALUES ('Bank', 'BANKS', 0)" + ) + db.execSQL( + "INSERT INTO $DETAILS_TABLE (previewId, question, answer, sequence, isSecret) " + + "VALUES (1, 'Password', 's3cret', 0, 1)" + ) + db.close() + + db = helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) + + // runMigrationsAndValidate already fails if the schema does not match 4.json exactly; + // these assertions cover what it cannot see - that the data survived, and that the new + // tables are usable rather than merely present. + db.query("SELECT * FROM $DETAILS_TABLE").apply { + assertThat(moveToFirst()).isTrue() + assertThat(getString(getColumnIndex("answer"))).isEqualTo("s3cret") + assertThat(getInt(getColumnIndex("isSecret"))).isEqualTo(1) + } + + db.execSQL( + "INSERT INTO totp_table " + + "(previewId, label, secret, issuer, algorithm, digits, periodSeconds) " + + "VALUES (1, 'Bank', 'GEZDGNBVGY3TQOJQ', 'Bank', 'SHA1', 6, 30)" + ) + db.execSQL( + "INSERT INTO detail_history_table (detailsId, answer, changedAt) " + + "VALUES (1, 'previous', 1700000000000)" + ) + db.query("SELECT COUNT(*) FROM totp_table").apply { + assertThat(moveToFirst()).isTrue() + assertThat(getInt(0)).isEqualTo(1) + } + db.query("SELECT COUNT(*) FROM detail_history_table").apply { + assertThat(moveToFirst()).isTrue() + assertThat(getInt(0)).isEqualTo(1) + } + } + + @Test + fun migration_3_to_4_is_safe_to_run_twice() { + // Every statement is CREATE ... IF NOT EXISTS, so an interrupted upgrade that runs again + // must not fail on the objects it already created. + var db = helper.createDatabase(DB_NAME, 3) + db.close() + db = helper.runMigrationsAndValidate(DB_NAME, 4, true, MIGRATION_3_4) + MIGRATION_3_4.migrate(db) + } + + @Test + fun migration_chain_1_to_4_runs_end_to_end() { + var db = helper.createDatabase(DB_NAME, 1) + db.execSQL( + "INSERT INTO $PREVIEW_TABLE (heading, categoryName, priority) " + + "VALUES ('Old Entry', 'MAILS', 2)" + ) + db.execSQL( + "INSERT INTO $DETAILS_TABLE (question, answer, priority, headingName, categoryName) " + + "VALUES ('Password', 'legacy', 2, 'Old Entry', 'MAILS')" + ) + db.close() + + db = helper.runMigrationsAndValidate( + DB_NAME, 4, true, MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4 + ) + + db.query("SELECT * FROM $DETAILS_TABLE").apply { + assertThat(moveToFirst()).isTrue() + assertThat(getString(getColumnIndex("answer"))).isEqualTo("legacy") + } + } } diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupFormat.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupFormat.kt index 79457d2..17e5fab 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupFormat.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupFormat.kt @@ -28,7 +28,12 @@ internal object BackupFormat { val MAGIC = "PKBACKUP".toByteArray(Charsets.US_ASCII) - const val FORMAT_VERSION = 2 + /** + * 3 carries payload schema 2 (isSecret and authenticators). Bumped alongside the payload so + * an older build says "made by a newer version of PassKey" instead of failing to parse an + * unknown key and blaming the password. + */ + const val FORMAT_VERSION = 3 const val KDF_PBKDF2_HMAC_SHA512 = 1 const val SALT_BYTES = 16 diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt index 10501ed..7dc11c0 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupPayload.kt @@ -16,13 +16,19 @@ internal data class BackupPayload( val previews: List ) { companion object { - const val SCHEMA_VERSION = 1 + /** + * 2 adds `isSecret` and `totp`. A v1 file still restores: both are optional with + * defaults, and a v1 reader is kept out by the *format* version rather than being left + * to fail on an unknown key and report "wrong password". + */ + const val SCHEMA_VERSION = 2 /** Guards against a hostile file exhausting memory. */ const val MAX_PREVIEWS = 10_000 const val MAX_HEADING_CHARS = 256 const val MAX_QUESTION_CHARS = 256 const val MAX_ANSWER_CHARS = 4_096 + const val MAX_SECRET_CHARS = 512 } } @@ -31,12 +37,29 @@ internal data class BackupPreview( val heading: String, val categoryName: String, val sequence: Long = 0, - val details: List = emptyList() + val details: List = emptyList(), + val totp: List = emptyList() ) @Serializable internal data class BackupDetail( val question: String, val answer: String, - val sequence: Long = 0 + val sequence: Long = 0, + /** + * Carried since schema 2. Without it a restore reset every entry to keyword matching, which + * classifies nothing outside English - the exact bug isSecret exists to fix. + */ + val isSecret: Boolean = false +) + +/** The Base32 secret, as printed, plus the parameters needed to reproduce the same codes. */ +@Serializable +internal data class BackupTotp( + val label: String, + val secret: String, + val issuer: String? = null, + val algorithm: String = "SHA1", + val digits: Int = 6, + val periodSeconds: Int = 30 ) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt index a0ca357..17ff6b5 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/backup/BackupRepository.kt @@ -11,6 +11,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import com.bhardwaj.passkey.domain.totp.Base32 +import com.bhardwaj.passkey.domain.totp.TotpAlgorithm +import com.bhardwaj.passkey.domain.totp.TotpConfig import kotlinx.serialization.json.Json import java.util.Arrays import java.util.zip.GZIPInputStream @@ -59,7 +62,17 @@ class BackupRepository @Inject constructor( categoryName = preview.category.name, sequence = preview.sequence, details = repository.getDetailsByPreviewId(preview.id).first() - .map { BackupDetail(it.question, it.answer, it.sequence) } + .map { BackupDetail(it.question, it.answer, it.sequence, it.isSecret) }, + totp = repository.getTotpByPreviewId(preview.id).first().map { + BackupTotp( + label = it.label, + secret = Base32.encode(it.config.secret), + issuer = it.config.issuer, + algorithm = it.config.algorithm.name, + digits = it.config.digits, + periodSeconds = it.config.periodSeconds + ) + } ) } }.getOrElse { return@withContext Result.failure(BackupException(BackupError.ReadFailed)) } @@ -200,11 +213,42 @@ class BackupRepository @Inject constructor( previewId = previewId, question = question, answer = answer, - sequence = detail.sequence + sequence = detail.sequence, + isSecret = detail.isSecret ) detailsAdded++ } } + + backupPreview.totp.forEach { totp -> + // A secret that will not decode produces codes that never verify, with + // nothing on screen to say why. Skip the row and count it instead. + val secret = totp.secret + .takeIf { it.length <= BackupPayload.MAX_SECRET_CHARS } + ?.let { Base32.decode(it) } + val label = totp.label.trim() + if (secret == null || label.isEmpty() || + label.length > BackupPayload.MAX_HEADING_CHARS + ) { + skipped++ + return@forEach + } + repository.createTotp( + previewId = previewId, + label = label, + config = TotpConfig( + secret = secret, + issuer = totp.issuer, + account = label, + algorithm = TotpAlgorithm.fromNameOrDefault(totp.algorithm), + digits = totp.digits.takeIf { it in TotpConfig.DIGIT_RANGE } + ?: TotpConfig.DEFAULT_DIGITS, + periodSeconds = totp.periodSeconds + .takeIf { it in TotpConfig.PERIOD_RANGE } + ?: TotpConfig.DEFAULT_PERIOD_SECONDS + ) + ) + } } } Result.success( diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt index 67fccea..beb0f2e 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/PasskeyDatabase.kt @@ -4,15 +4,26 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.bhardwaj.passkey.data.local.dao.DetailsDao import com.bhardwaj.passkey.data.local.dao.PreviewDao +import com.bhardwaj.passkey.data.local.dao.DetailHistoryDao +import com.bhardwaj.passkey.data.local.dao.TotpDao +import com.bhardwaj.passkey.data.local.entity.DetailHistoryEntity import com.bhardwaj.passkey.data.local.entity.DetailsEntity +import com.bhardwaj.passkey.data.local.entity.TotpEntity import com.bhardwaj.passkey.data.local.entity.PreviewEntity @Database( - entities = [PreviewEntity::class, DetailsEntity::class], - version = 3, + entities = [ + PreviewEntity::class, + DetailsEntity::class, + TotpEntity::class, + DetailHistoryEntity::class + ], + version = 4, exportSchema = true ) abstract class PasskeyDatabase : RoomDatabase() { abstract val previewDao: PreviewDao abstract val detailsDao: DetailsDao + abstract val totpDao: TotpDao + abstract val historyDao: DetailHistoryDao } \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt index 8adaf31..5641c4a 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/VaultDatabaseProvider.kt @@ -5,6 +5,7 @@ import androidx.room.Room import com.bhardwaj.passkey.utils.Constants import com.bhardwaj.passkey.utils.MIGRATION_1_2 import com.bhardwaj.passkey.utils.MIGRATION_2_3 +import com.bhardwaj.passkey.utils.MIGRATION_3_4 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -49,7 +50,7 @@ class VaultDatabaseProvider @Inject constructor( klass = PasskeyDatabase::class.java, name = Constants.PASS_KEY_DATABASE ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .openHelperFactory( // clearPassphrase = false: the default zeroes the array after the first // open, and Room may reopen the helper (after close, or a migration), which diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailHistoryDao.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailHistoryDao.kt new file mode 100644 index 0000000..3209f9c --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/DetailHistoryDao.kt @@ -0,0 +1,34 @@ +package com.bhardwaj.passkey.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.bhardwaj.passkey.data.local.entity.DetailHistoryEntity +import com.bhardwaj.passkey.utils.Constants.Companion.DETAIL_HISTORY_TABLE +import kotlinx.coroutines.flow.Flow + +@Dao +interface DetailHistoryDao { + @Query("SELECT * FROM $DETAIL_HISTORY_TABLE WHERE detailsId=:detailId ORDER BY changedAt DESC") + fun getForDetail(detailId: Long): Flow> + + @Query("SELECT changedAt FROM $DETAIL_HISTORY_TABLE WHERE detailsId=:detailId ORDER BY changedAt DESC LIMIT 1") + suspend fun lastChangedAt(detailId: Long): Long? + + @Insert + suspend fun insert(entry: DetailHistoryEntity): Long + + /** + * Keeps the newest [keep] versions of one detail. History is a convenience, not an archive: + * an unbounded one would grow forever inside a vault the user never prunes. + */ + @Query( + "DELETE FROM $DETAIL_HISTORY_TABLE WHERE detailsId=:detailId AND historyId NOT IN " + + "(SELECT historyId FROM $DETAIL_HISTORY_TABLE WHERE detailsId=:detailId " + + "ORDER BY changedAt DESC LIMIT :keep)" + ) + suspend fun trim(detailId: Long, keep: Int) + + @Query("DELETE FROM $DETAIL_HISTORY_TABLE") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/dao/TotpDao.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/TotpDao.kt new file mode 100644 index 0000000..4fc8781 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/dao/TotpDao.kt @@ -0,0 +1,24 @@ +package com.bhardwaj.passkey.data.local.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import com.bhardwaj.passkey.data.local.entity.TotpEntity +import com.bhardwaj.passkey.utils.Constants.Companion.TOTP_TABLE +import kotlinx.coroutines.flow.Flow + +@Dao +interface TotpDao { + @Query("SELECT * FROM $TOTP_TABLE WHERE previewId=:previewId ORDER BY totpId ASC") + fun getByPreviewId(previewId: Long): Flow> + + @Upsert + suspend fun upsert(totp: TotpEntity): Long + + @Delete + suspend fun delete(totp: TotpEntity) + + @Query("DELETE FROM $TOTP_TABLE") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailHistoryEntity.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailHistoryEntity.kt new file mode 100644 index 0000000..21450b3 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailHistoryEntity.kt @@ -0,0 +1,38 @@ +package com.bhardwaj.passkey.data.local.entity + +import androidx.annotation.Keep +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE +import com.bhardwaj.passkey.utils.Constants.Companion.DETAIL_HISTORY_TABLE + +/** + * A value a detail used to hold, kept so a password change can be undone and so the app can say + * how long the current one has been in use. + * + * Written by the repository whenever a secret's value changes, which is what stops it from being + * something a call site has to remember. + */ +@Keep +@Entity( + tableName = DETAIL_HISTORY_TABLE, + foreignKeys = [ + ForeignKey( + entity = DetailsEntity::class, + parentColumns = ["detailsId"], + childColumns = ["detailsId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("detailsId")] +) +data class DetailHistoryEntity( + @PrimaryKey(autoGenerate = true) + val historyId: Long? = null, + val detailsId: Long, + val answer: String, + /** Wall-clock: this is shown to the user as a date, not used to measure an interval. */ + val changedAt: Long +) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt index bbd7466..826b2ce 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/DetailsEntity.kt @@ -2,11 +2,20 @@ package com.bhardwaj.passkey.data.local.entity import androidx.annotation.Keep import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE @Keep -@Entity(tableName = DETAILS_TABLE) +/** + * [previewId] is indexed: every lookup of an entry's details filters on it, and without an index + * each one was a full table scan. + * + * No foreign key, unlike the two tables added in v4. Adding one to an existing table means + * rebuilding it, and this is the table holding every password the user owns; the cascade is + * enforced in a single transaction by the repository instead. + */ +@Entity(tableName = DETAILS_TABLE, indices = [Index("previewId")]) data class DetailsEntity( @PrimaryKey(autoGenerate = true) val detailsId: Long? = null, diff --git a/app/src/main/java/com/bhardwaj/passkey/data/local/entity/TotpEntity.kt b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/TotpEntity.kt new file mode 100644 index 0000000..5bf6392 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/data/local/entity/TotpEntity.kt @@ -0,0 +1,44 @@ +package com.bhardwaj.passkey.data.local.entity + +import androidx.annotation.Keep +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_TABLE +import com.bhardwaj.passkey.utils.Constants.Companion.TOTP_TABLE + +/** + * An authenticator secret belonging to an entry. + * + * Unlike details, this table is new, so it can carry the foreign key the older tables cannot + * gain without a full rebuild: deleting an entry takes its authenticator with it, enforced by + * SQLite rather than by remembering to. + * + * The secret is stored Base32-encoded - the form every authenticator prints - inside a database + * that is already encrypted as a whole. + */ +@Keep +@Entity( + tableName = TOTP_TABLE, + foreignKeys = [ + ForeignKey( + entity = PreviewEntity::class, + parentColumns = ["previewId"], + childColumns = ["previewId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("previewId")] +) +data class TotpEntity( + @PrimaryKey(autoGenerate = true) + val totpId: Long? = null, + val previewId: Long, + val label: String, + val secret: String, + val issuer: String? = null, + val algorithm: String = "SHA1", + val digits: Int = 6, + val periodSeconds: Int = 30 +) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt b/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt index 33de441..1eb6bd9 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/mapper/VaultMappers.kt @@ -1,10 +1,17 @@ package com.bhardwaj.passkey.data.mapper +import com.bhardwaj.passkey.data.local.entity.DetailHistoryEntity import com.bhardwaj.passkey.data.local.entity.DetailsEntity import com.bhardwaj.passkey.data.local.entity.PreviewEntity +import com.bhardwaj.passkey.data.local.entity.TotpEntity import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.PasswordHistoryEntry import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.domain.model.TotpEntry +import com.bhardwaj.passkey.domain.totp.Base32 +import com.bhardwaj.passkey.domain.totp.TotpAlgorithm +import com.bhardwaj.passkey.domain.totp.TotpConfig internal fun PreviewEntity.toDomain(): Preview = Preview( // Safe by construction: Room always populates the primary key on a read. A row without one @@ -39,3 +46,45 @@ internal fun Detail.toEntity(): DetailsEntity = DetailsEntity( sequence = sequence, isSecret = isSecret ) + +/** + * Returns null for a row whose secret is not valid Base32. + * + * That should be unreachable - the secret is validated before it is stored - but a code + * generated from a half-decoded secret would be silently wrong, which is worse than an + * authenticator that visibly is not there. + */ +internal fun TotpEntity.toDomain(): TotpEntry? { + val decoded = Base32.decode(secret) ?: return null + return TotpEntry( + id = requireNotNull(totpId) { "authenticator read from the database without an id" }, + previewId = previewId, + label = label, + config = TotpConfig( + secret = decoded, + issuer = issuer, + account = label, + algorithm = TotpAlgorithm.fromNameOrDefault(algorithm), + digits = digits, + periodSeconds = periodSeconds + ) + ) +} + +internal fun TotpEntry.toEntity(): TotpEntity = TotpEntity( + totpId = id.takeIf { it != 0L }, + previewId = previewId, + label = label, + secret = Base32.encode(config.secret), + issuer = config.issuer, + algorithm = config.algorithm.name, + digits = config.digits, + periodSeconds = config.periodSeconds +) + +internal fun DetailHistoryEntity.toDomain(): PasswordHistoryEntry = PasswordHistoryEntry( + id = requireNotNull(historyId) { "history row read from the database without an id" }, + detailId = detailsId, + answer = answer, + changedAt = changedAt +) diff --git a/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt b/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt index 264663b..d999c9d 100644 --- a/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt +++ b/app/src/main/java/com/bhardwaj/passkey/data/repository/PasskeyRepositoryImpl.kt @@ -7,8 +7,12 @@ import com.bhardwaj.passkey.data.mapper.toDomain import com.bhardwaj.passkey.data.mapper.toEntity import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.data.local.entity.DetailHistoryEntity +import com.bhardwaj.passkey.domain.model.PasswordHistoryEntry import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.domain.model.TotpEntry import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import com.bhardwaj.passkey.domain.totp.TotpConfig import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -17,9 +21,16 @@ import kotlinx.coroutines.flow.map @OptIn(ExperimentalCoroutinesApi::class) class PasskeyRepositoryImpl( - private val vault: VaultDatabaseProvider + private val vault: VaultDatabaseProvider, + /** Injected so history timestamps are assertable; production reads the wall clock. */ + private val now: () -> Long = System::currentTimeMillis ) : PasskeyRepository { + private companion object { + /** History is a convenience, not an archive. */ + const val HISTORY_PER_DETAIL = 10 + } + /** * Room Flows are resolved through the vault's *current* database rather than a DAO captured * at construction. @@ -94,8 +105,32 @@ class PasskeyRepositoryImpl( ).toEntity() ) + /** + * Records the previous value before overwriting a secret. + * + * Done here rather than at the call sites because there is more than one of them - the + * editor, import, and autofill's save - and a history that depends on every one of them + * remembering is a history with holes in it. + */ override suspend fun updateDetail(detail: Detail) { - vault.requireDb().detailsDao.upsertDetails(detail.toEntity()) + val db = vault.requireDb() + db.withTransaction { + val existing = db.detailsDao.getDetailById(detail.id) + val wasSecret = existing?.isSecret == true || detail.isSecret + if (existing != null && wasSecret && existing.answer != detail.answer && + existing.answer.isNotBlank() + ) { + db.historyDao.insert( + DetailHistoryEntity( + detailsId = detail.id, + answer = existing.answer, + changedAt = now() + ) + ) + db.historyDao.trim(detail.id, HISTORY_PER_DETAIL) + } + db.detailsDao.upsertDetails(detail.toEntity()) + } } override suspend fun deleteDetail(detail: Detail) { @@ -110,11 +145,36 @@ class PasskeyRepositoryImpl( vault.requireDb().detailsDao.updateDetailSequence(detailId, sequence) } + override fun getTotpByPreviewId(previewId: Long): Flow> = + vault.database.flatMapLatest { db -> + db?.totpDao?.getByPreviewId(previewId)?.map { rows -> rows.mapNotNull { it.toDomain() } } + ?: flowOf(emptyList()) + } + + override suspend fun createTotp(previewId: Long, label: String, config: TotpConfig): Long = + vault.requireDb().totpDao.upsert( + TotpEntry(id = 0, previewId = previewId, label = label, config = config).toEntity() + ) + + override suspend fun deleteTotp(entry: TotpEntry) { + vault.requireDb().totpDao.delete(entry.toEntity()) + } + + override fun getHistoryForDetail(detailId: Long): Flow> = + vaultFlow({ it.historyDao.getForDetail(detailId) }) { it.toDomain() } + + override suspend fun lastChangedAt(detailId: Long): Long? = + vault.requireDb().historyDao.lastChangedAt(detailId) + override suspend fun runInTransaction(block: suspend () -> R): R = vault.requireDb().withTransaction { block() } override suspend fun deleteAll() { val db = vault.requireDb() + // Children first: the foreign keys cascade, but deleteAll is also used on a database + // where foreign keys may be off during an import transaction. + db.historyDao.deleteAll() + db.totpDao.deleteAll() db.detailsDao.deleteAllDetails() db.previewDao.deleteAllPreviews() } diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/TotpEntry.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/TotpEntry.kt new file mode 100644 index 0000000..de1f050 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/TotpEntry.kt @@ -0,0 +1,25 @@ +package com.bhardwaj.passkey.domain.model + +import com.bhardwaj.passkey.domain.totp.TotpConfig + +/** An authenticator attached to an entry. */ +data class TotpEntry( + val id: Long, + val previewId: Long, + val label: String, + val config: TotpConfig +) + +/** + * A value a secret used to hold. + * + * [changedAt] is wall-clock because it is shown as a date. Everywhere the app measures an + * interval - the auto-lock timer - it uses elapsedRealtime instead, which a clock change cannot + * move. + */ +data class PasswordHistoryEntry( + val id: Long, + val detailId: Long, + val answer: String, + val changedAt: Long +) diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt index dbae4f1..bea8446 100644 --- a/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt +++ b/app/src/main/java/com/bhardwaj/passkey/domain/repository/PasskeyRepository.kt @@ -2,7 +2,10 @@ package com.bhardwaj.passkey.domain.repository import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.PasswordHistoryEntry import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.domain.model.TotpEntry +import com.bhardwaj.passkey.domain.totp.TotpConfig import kotlinx.coroutines.flow.Flow /** @@ -58,6 +61,21 @@ interface PasskeyRepository { suspend fun updateDetailSequence(detailId: Long, sequence: Long) + // Authenticator codes + + fun getTotpByPreviewId(previewId: Long): Flow> + + suspend fun createTotp(previewId: Long, label: String, config: TotpConfig): Long + + suspend fun deleteTotp(entry: TotpEntry) + + // Password history + + fun getHistoryForDetail(detailId: Long): Flow> + + /** When this secret last changed, or null if it has never been edited. */ + suspend fun lastChangedAt(detailId: Long): Long? + // Cross-cutting /** diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/totp/OtpAuth.kt b/app/src/main/java/com/bhardwaj/passkey/domain/totp/OtpAuth.kt new file mode 100644 index 0000000..a66da2a --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/totp/OtpAuth.kt @@ -0,0 +1,143 @@ +package com.bhardwaj.passkey.domain.totp + +/** + * Base32 as RFC 4648 defines it, and as every authenticator prints it. + * + * Deliberately forgiving on input: secrets are shown to people in groups of four with spaces, + * sometimes lowercase, often without padding. Rejecting any of those would only mean the user + * retypes what they already copied correctly. + */ +object Base32 { + + private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + fun decode(encoded: String): ByteArray? { + val cleaned = encoded.trim() + .replace(" ", "") + .replace("-", "") + .trimEnd('=') + .uppercase() + if (cleaned.isEmpty()) return null + + var buffer = 0 + var bitsLeft = 0 + val out = ArrayList(cleaned.length * 5 / 8 + 1) + for (character in cleaned) { + val value = ALPHABET.indexOf(character) + if (value < 0) return null + buffer = (buffer shl 5) or value + bitsLeft += 5 + if (bitsLeft >= 8) { + bitsLeft -= 8 + out += ((buffer shr bitsLeft) and 0xff).toByte() + } + } + return if (out.isEmpty()) null else out.toByteArray() + } + + fun encode(bytes: ByteArray): String { + val builder = StringBuilder() + var buffer = 0 + var bitsLeft = 0 + for (byte in bytes) { + buffer = (buffer shl 8) or (byte.toInt() and 0xff) + bitsLeft += 8 + while (bitsLeft >= 5) { + bitsLeft -= 5 + builder.append(ALPHABET[(buffer shr bitsLeft) and 0x1f]) + } + } + if (bitsLeft > 0) builder.append(ALPHABET[(buffer shl (5 - bitsLeft)) and 0x1f]) + return builder.toString() + } +} + +/** + * Reads the `otpauth://totp/...` URI behind every authenticator QR code. + * + * Parsed by hand rather than with android.net.Uri so it stays testable on the JVM, and because + * the label half of the path is percent-encoded in a way Uri's own splitting does not help with. + */ +object OtpAuthUri { + + private const val SCHEME = "otpauth://" + private const val TOTP_TYPE = "totp" + + /** + * Returns null for anything that is not a well-formed TOTP URI - including the HOTP variant, + * which counts events rather than time and would silently produce codes that never verify. + */ + fun parse(raw: String): TotpConfig? { + val text = raw.trim() + if (!text.startsWith(SCHEME, ignoreCase = true)) return null + val body = text.substring(SCHEME.length) + + val type = body.substringBefore('/').substringBefore('?').lowercase() + if (type != TOTP_TYPE) return null + + val afterType = body.substringAfter('/', missingDelimiterValue = "") + val label = afterType.substringBefore('?').decodePercent() + val query = afterType.substringAfter('?', missingDelimiterValue = "") + val params = query.split('&') + .mapNotNull { pair -> + if (pair.isBlank()) return@mapNotNull null + val key = pair.substringBefore('=').lowercase() + val value = pair.substringAfter('=', missingDelimiterValue = "").decodePercent() + key to value + } + .toMap() + + val secret = params["secret"]?.let { Base32.decode(it) } ?: return null + + // "Issuer:account" in the label, with the issuer= parameter winning when both exist. + val labelIssuer = label.substringBefore(':', missingDelimiterValue = "").trim() + val account = label.substringAfter(':', missingDelimiterValue = label).trim() + + return TotpConfig( + secret = secret, + issuer = params["issuer"]?.trim()?.takeIf { it.isNotEmpty() } + ?: labelIssuer.takeIf { it.isNotEmpty() }, + account = account.takeIf { it.isNotEmpty() }, + algorithm = TotpAlgorithm.fromNameOrDefault(params["algorithm"]), + digits = params["digits"]?.toIntOrNull() + ?.takeIf { it in TotpConfig.DIGIT_RANGE } + ?: TotpConfig.DEFAULT_DIGITS, + periodSeconds = params["period"]?.toIntOrNull() + ?.takeIf { it in TotpConfig.PERIOD_RANGE } + ?: TotpConfig.DEFAULT_PERIOD_SECONDS + ) + } + + private fun String.decodePercent(): String { + if (!contains('%') && !contains('+')) return this + val bytes = ArrayList(length) + var index = 0 + while (index < length) { + val character = this[index] + when { + character == '%' && index + 2 < length -> { + val hex = substring(index + 1, index + 3).toIntOrNull(16) + if (hex == null) { + bytes += character.code.toByte() + index++ + } else { + bytes += hex.toByte() + index += 3 + } + } + + character == '+' -> { + bytes += ' '.code.toByte() + index++ + } + + else -> { + // Non-ASCII characters can appear unencoded in a hand-edited URI. + character.toString().toByteArray(Charsets.UTF_8).forEach { bytes += it } + index++ + } + } + } + return String(bytes.toByteArray(), Charsets.UTF_8) + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/totp/Totp.kt b/app/src/main/java/com/bhardwaj/passkey/domain/totp/Totp.kt new file mode 100644 index 0000000..27e15fd --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/totp/Totp.kt @@ -0,0 +1,105 @@ +package com.bhardwaj.passkey.domain.totp + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.math.pow + +enum class TotpAlgorithm(val macName: String) { + SHA1("HmacSHA1"), + SHA256("HmacSHA256"), + SHA512("HmacSHA512"); + + companion object { + fun fromNameOrDefault(name: String?): TotpAlgorithm = + entries.firstOrNull { it.name.equals(name?.trim(), ignoreCase = true) } ?: SHA1 + } +} + +/** + * One authenticator entry, exactly as an `otpauth://` URI describes it. + * + * [secret] is the decoded key, not the Base32 text: nothing above this layer should have to + * know how it was encoded, and the encoded form is another copy of the secret to keep track of. + */ +data class TotpConfig( + val secret: ByteArray, + val issuer: String? = null, + val account: String? = null, + val algorithm: TotpAlgorithm = TotpAlgorithm.SHA1, + val digits: Int = DEFAULT_DIGITS, + val periodSeconds: Int = DEFAULT_PERIOD_SECONDS +) { + override fun equals(other: Any?): Boolean = this === other || ( + other is TotpConfig && + secret.contentEquals(other.secret) && + issuer == other.issuer && + account == other.account && + algorithm == other.algorithm && + digits == other.digits && + periodSeconds == other.periodSeconds + ) + + override fun hashCode(): Int { + var result = secret.contentHashCode() + result = 31 * result + (issuer?.hashCode() ?: 0) + result = 31 * result + (account?.hashCode() ?: 0) + result = 31 * result + algorithm.hashCode() + result = 31 * result + digits + return 31 * result + periodSeconds + } + + companion object { + const val DEFAULT_DIGITS = 6 + const val DEFAULT_PERIOD_SECONDS = 30 + val DIGIT_RANGE = 6..8 + val PERIOD_RANGE = 15..120 + } +} + +/** + * RFC 6238 time-based one-time passwords. + * + * Entirely offline and entirely standard: the same counter, HMAC and dynamic truncation every + * authenticator implements, so a code generated here matches the one a phone across the room + * shows for the same secret. + */ +object TotpGenerator { + + /** [epochSeconds] is a parameter rather than a clock read, which is what makes this testable. */ + fun generate(config: TotpConfig, epochSeconds: Long): String { + val counter = epochSeconds / config.periodSeconds + return generate(config.secret, counter, config.algorithm, config.digits) + } + + fun secondsRemaining(config: TotpConfig, epochSeconds: Long): Int = + (config.periodSeconds - epochSeconds.mod(config.periodSeconds.toLong())).toInt() + + private fun generate( + secret: ByteArray, + counter: Long, + algorithm: TotpAlgorithm, + digits: Int + ): String { + val message = ByteArray(8) + var value = counter + for (i in 7 downTo 0) { + message[i] = (value and 0xff).toByte() + value = value ushr 8 + } + + val mac = Mac.getInstance(algorithm.macName).apply { + init(SecretKeySpec(secret, algorithm.macName)) + } + val hash = mac.doFinal(message) + + // Dynamic truncation: the low nibble of the last byte picks where to read from. + val offset = (hash[hash.size - 1].toInt() and 0x0f) + val binary = ((hash[offset].toInt() and 0x7f) shl 24) or + ((hash[offset + 1].toInt() and 0xff) shl 16) or + ((hash[offset + 2].toInt() and 0xff) shl 8) or + (hash[offset + 3].toInt() and 0xff) + + val modulus = 10.0.pow(digits).toInt() + return (binary % modulus).toString().padStart(digits, '0') + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt b/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt index d220940..4b40019 100644 --- a/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt +++ b/app/src/main/java/com/bhardwaj/passkey/utils/Constants.kt @@ -4,6 +4,8 @@ class Constants { companion object { const val DETAILS_TABLE = "details_table" const val PREVIEW_TABLE = "preview_table" + const val TOTP_TABLE = "totp_table" + const val DETAIL_HISTORY_TABLE = "detail_history_table" const val TEMP_DETAILS_TABLE = "temp_details_table" const val TEMP_PREVIEW_TABLE = "temp_preview_table" const val PASS_KEY_DATABASE = "passkey_database" diff --git a/app/src/main/java/com/bhardwaj/passkey/utils/Migration_3_4.kt b/app/src/main/java/com/bhardwaj/passkey/utils/Migration_3_4.kt new file mode 100644 index 0000000..ed8291d --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/utils/Migration_3_4.kt @@ -0,0 +1,61 @@ +package com.bhardwaj.passkey.utils + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.bhardwaj.passkey.utils.Constants.Companion.DETAILS_TABLE +import com.bhardwaj.passkey.utils.Constants.Companion.DETAIL_HISTORY_TABLE +import com.bhardwaj.passkey.utils.Constants.Companion.PREVIEW_TABLE +import com.bhardwaj.passkey.utils.Constants.Companion.TOTP_TABLE + +/** + * Adds authenticator codes, password history, and the index details_table never had. + * + * Every statement here is additive: one index and two new tables. No existing row is read, + * rewritten or moved, so there is nothing for an interrupted run to leave half-done - the tables + * either exist or they do not, and IF NOT EXISTS makes a retry harmless. + * + * The statements are copied verbatim from Room's own exported schema (schemas/4.json). They have + * to match it exactly, down to the foreign-key clause and the index names, or Room's identity + * check fails at the first open after upgrading and every install refuses to start. + */ +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Every lookup of an entry's details filters on previewId and was a full table scan. + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_${DETAILS_TABLE}_previewId` " + + "ON `$DETAILS_TABLE` (`previewId`)" + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `$TOTP_TABLE` (" + + "`totpId` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`previewId` INTEGER NOT NULL, " + + "`label` TEXT NOT NULL, " + + "`secret` TEXT NOT NULL, " + + "`issuer` TEXT, " + + "`algorithm` TEXT NOT NULL, " + + "`digits` INTEGER NOT NULL, " + + "`periodSeconds` INTEGER NOT NULL, " + + "FOREIGN KEY(`previewId`) REFERENCES `$PREVIEW_TABLE`(`previewId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_${TOTP_TABLE}_previewId` " + + "ON `$TOTP_TABLE` (`previewId`)" + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `$DETAIL_HISTORY_TABLE` (" + + "`historyId` INTEGER PRIMARY KEY AUTOINCREMENT, " + + "`detailsId` INTEGER NOT NULL, " + + "`answer` TEXT NOT NULL, " + + "`changedAt` INTEGER NOT NULL, " + + "FOREIGN KEY(`detailsId`) REFERENCES `$DETAILS_TABLE`(`detailsId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_${DETAIL_HISTORY_TABLE}_detailsId` " + + "ON `$DETAIL_HISTORY_TABLE` (`detailsId`)" + ) + } +} diff --git a/app/src/test/java/com/bhardwaj/passkey/BackupPayloadTest.kt b/app/src/test/java/com/bhardwaj/passkey/BackupPayloadTest.kt new file mode 100644 index 0000000..a4d6ece --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/BackupPayloadTest.kt @@ -0,0 +1,90 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.data.backup.BackupDetail +import com.bhardwaj.passkey.data.backup.BackupPayload +import com.bhardwaj.passkey.data.backup.BackupPreview +import com.bhardwaj.passkey.data.backup.BackupTotp +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * The backup file is the only copy of a vault that leaves the device, so what it does and does + * not carry is worth asserting rather than assuming. + */ +class BackupPayloadTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `a schema 2 payload round-trips with its secrets and authenticators`() { + val payload = BackupPayload( + exportedAt = 1_700_000_000_000L, + appVersion = "5.8.0", + previews = listOf( + BackupPreview( + heading = "Bank", + categoryName = "BANKS", + details = listOf(BackupDetail("Password", "s3cret", 0, isSecret = true)), + totp = listOf( + BackupTotp( + label = "Bank", + secret = "GEZDGNBVGY3TQOJQ", + issuer = "Bank", + algorithm = "SHA256", + digits = 8, + periodSeconds = 60 + ) + ) + ) + ) + ) + + val decoded = json.decodeFromString(json.encodeToString(payload)) + + assertThat(decoded).isEqualTo(payload) + assertThat(decoded.schema).isEqualTo(2) + assertThat(decoded.previews.single().details.single().isSecret).isTrue() + assertThat(decoded.previews.single().totp.single().digits).isEqualTo(8) + } + + @Test + fun `a schema 1 file written by 5_6 still restores`() { + // The two fields added in schema 2 are optional with defaults, so an older backup + // decodes rather than failing and being reported to the user as a wrong password. + val v1 = """ + {"schema":1,"exportedAt":1700000000000,"appVersion":"5.6.0","previews":[ + {"heading":"Gmail","categoryName":"MAILS","sequence":0, + "details":[{"question":"Password","answer":"s3cret","sequence":0}]} + ]} + """.trimIndent() + + val decoded = json.decodeFromString(v1) + + assertThat(decoded.previews.single().heading).isEqualTo("Gmail") + assertThat(decoded.previews.single().details.single().isSecret).isFalse() + assertThat(decoded.previews.single().totp).isEmpty() + } + + @Test + fun `nothing install-local crosses the file boundary`() { + // Details nest under their preview rather than carrying previewId, so restoring into a + // different install cannot collide with ids that already exist there. + val encoded = json.encodeToString( + BackupPayload( + exportedAt = 0L, + appVersion = "5.8.0", + previews = listOf( + BackupPreview( + heading = "Gmail", + categoryName = "MAILS", + details = listOf(BackupDetail("Password", "s3cret")) + ) + ) + ) + ) + + assertThat(encoded).doesNotContain("previewId") + assertThat(encoded).doesNotContain("detailsId") + } +} diff --git a/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt b/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt index 0f6daa9..2445479 100644 --- a/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt +++ b/app/src/test/java/com/bhardwaj/passkey/FakePasskeyRepository.kt @@ -2,8 +2,11 @@ package com.bhardwaj.passkey import com.bhardwaj.passkey.domain.model.Category import com.bhardwaj.passkey.domain.model.Detail +import com.bhardwaj.passkey.domain.model.PasswordHistoryEntry import com.bhardwaj.passkey.domain.model.Preview +import com.bhardwaj.passkey.domain.model.TotpEntry import com.bhardwaj.passkey.domain.repository.PasskeyRepository +import com.bhardwaj.passkey.domain.totp.TotpConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -19,8 +22,13 @@ class FakePasskeyRepository : PasskeyRepository { private val previews = MutableStateFlow>(emptyList()) private val details = MutableStateFlow>(emptyList()) + private val totps = MutableStateFlow>(emptyList()) + private val history = MutableStateFlow>(emptyList()) private var nextId = 1L + /** Mirrors the real repository, which writes history itself rather than trusting callers. */ + var clock: Long = 1_700_000_000_000L + /** Counts writes so tests can assert nothing touched the database. */ var writeCount = 0 private set @@ -98,6 +106,12 @@ class FakePasskeyRepository : PasskeyRepository { override suspend fun updateDetail(detail: Detail) { writeCount++ + val existing = details.value.firstOrNull { it.id == detail.id } + if (existing != null && (existing.isSecret || detail.isSecret) && + existing.answer != detail.answer && existing.answer.isNotBlank() + ) { + history.value += PasswordHistoryEntry(nextId++, detail.id, existing.answer, clock) + } details.value = details.value.map { if (it.id == detail.id) detail else it } } @@ -118,11 +132,38 @@ class FakePasskeyRepository : PasskeyRepository { } } + fun currentTotps(): List = totps.value + + override fun getTotpByPreviewId(previewId: Long): Flow> = + totps.map { list -> list.filter { it.previewId == previewId } } + + override suspend fun createTotp(previewId: Long, label: String, config: TotpConfig): Long { + writeCount++ + val id = nextId++ + totps.value += TotpEntry(id, previewId, label, config) + return id + } + + override suspend fun deleteTotp(entry: TotpEntry) { + writeCount++ + totps.value = totps.value.filterNot { it.id == entry.id } + } + + override fun getHistoryForDetail(detailId: Long): Flow> = + history.map { list -> + list.filter { it.detailId == detailId }.sortedByDescending { it.changedAt } + } + + override suspend fun lastChangedAt(detailId: Long): Long? = + history.value.filter { it.detailId == detailId }.maxOfOrNull { it.changedAt } + override suspend fun runInTransaction(block: suspend () -> R): R = block() override suspend fun deleteAll() { writeCount++ previews.value = emptyList() details.value = emptyList() + totps.value = emptyList() + history.value = emptyList() } } diff --git a/app/src/test/java/com/bhardwaj/passkey/TotpTest.kt b/app/src/test/java/com/bhardwaj/passkey/TotpTest.kt new file mode 100644 index 0000000..031acdc --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/TotpTest.kt @@ -0,0 +1,151 @@ +package com.bhardwaj.passkey + +import com.bhardwaj.passkey.domain.totp.Base32 +import com.bhardwaj.passkey.domain.totp.OtpAuthUri +import com.bhardwaj.passkey.domain.totp.TotpAlgorithm +import com.bhardwaj.passkey.domain.totp.TotpConfig +import com.bhardwaj.passkey.domain.totp.TotpGenerator +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TotpTest { + + // RFC 6238 Appendix B. The seed is repeated to the length each algorithm expects. + private val sha1Seed = "12345678901234567890".toByteArray() + private val sha256Seed = "12345678901234567890123456789012".toByteArray() + private val sha512Seed = + "1234567890123456789012345678901234567890123456789012345678901234".toByteArray() + + private fun config( + secret: ByteArray, + algorithm: TotpAlgorithm = TotpAlgorithm.SHA1, + digits: Int = 8 + ) = TotpConfig(secret = secret, algorithm = algorithm, digits = digits) + + @Test + fun `the RFC 6238 SHA-1 test vectors all match`() { + val expected = mapOf( + 59L to "94287082", + 1111111109L to "07081804", + 1111111111L to "14050471", + 1234567890L to "89005924", + 2000000000L to "69279037", + 20000000000L to "65353130" + ) + expected.forEach { (time, code) -> + assertThat(TotpGenerator.generate(config(sha1Seed), time)).isEqualTo(code) + } + } + + @Test + fun `the SHA-256 and SHA-512 vectors match too`() { + assertThat(TotpGenerator.generate(config(sha256Seed, TotpAlgorithm.SHA256), 59L)) + .isEqualTo("46119246") + assertThat(TotpGenerator.generate(config(sha512Seed, TotpAlgorithm.SHA512), 59L)) + .isEqualTo("90693936") + } + + @Test + fun `a six digit code is the eight digit one truncated from the left`() { + // Not a property of this implementation but of the spec: digits is a modulus. + assertThat(TotpGenerator.generate(config(sha1Seed, digits = 6), 59L)).isEqualTo("287082") + } + + @Test + fun `a code is padded rather than shortened when the truncation is small`() { + // A leading zero is significant; dropping it produces a code the server rejects. + val codes = (0L until 2000L step 30).map { + TotpGenerator.generate(config(sha1Seed, digits = 6), it) + } + assertThat(codes.all { it.length == 6 }).isTrue() + } + + @Test + fun `the countdown runs from the period down to one`() { + val config = TotpConfig(secret = sha1Seed) + assertThat(TotpGenerator.secondsRemaining(config, 0L)).isEqualTo(30) + assertThat(TotpGenerator.secondsRemaining(config, 1L)).isEqualTo(29) + assertThat(TotpGenerator.secondsRemaining(config, 29L)).isEqualTo(1) + assertThat(TotpGenerator.secondsRemaining(config, 30L)).isEqualTo(30) + } + + @Test + fun `base32 round-trips and matches the canonical encoding`() { + assertThat(Base32.encode(sha1Seed)).isEqualTo("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + assertThat(Base32.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")).isEqualTo(sha1Seed) + } + + @Test + fun `base32 accepts a secret as it is printed for people to read`() { + // Grouped, lowercase and unpadded - all three are how secrets actually arrive. + assertThat(Base32.decode("gezd gnbv gy3t qojq gezd gnbv gy3t qojq")).isEqualTo(sha1Seed) + assertThat(Base32.decode("JBSWY3DP")).isEqualTo("Hello".toByteArray()) + assertThat(Base32.decode("JBSWY3DP===")).isEqualTo("Hello".toByteArray()) + } + + @Test + fun `base32 rejects characters outside the alphabet rather than guessing`() { + assertThat(Base32.decode("JBSWY3D!")).isNull() + assertThat(Base32.decode("01890")).isNull() + assertThat(Base32.decode("")).isNull() + } + + @Test + fun `an otpauth uri supplies every parameter`() { + val config = OtpAuthUri.parse( + "otpauth://totp/ACME%20Co:alice%40example.com" + + "?secret=GEZDGNBVGY3TQOJQ&issuer=ACME%20Co&algorithm=SHA256&digits=8&period=60" + ) + assertThat(config).isNotNull() + assertThat(config!!.issuer).isEqualTo("ACME Co") + assertThat(config.account).isEqualTo("alice@example.com") + assertThat(config.algorithm).isEqualTo(TotpAlgorithm.SHA256) + assertThat(config.digits).isEqualTo(8) + assertThat(config.periodSeconds).isEqualTo(60) + } + + @Test + fun `the defaults apply when the uri omits them`() { + val config = OtpAuthUri.parse("otpauth://totp/alice?secret=GEZDGNBVGY3TQOJQ")!! + assertThat(config.algorithm).isEqualTo(TotpAlgorithm.SHA1) + assertThat(config.digits).isEqualTo(6) + assertThat(config.periodSeconds).isEqualTo(30) + assertThat(config.account).isEqualTo("alice") + assertThat(config.issuer).isNull() + } + + @Test + fun `out of range parameters fall back instead of producing codes nothing accepts`() { + val config = OtpAuthUri.parse( + "otpauth://totp/alice?secret=GEZDGNBVGY3TQOJQ&digits=99&period=0" + )!! + assertThat(config.digits).isEqualTo(6) + assertThat(config.periodSeconds).isEqualTo(30) + } + + @Test + fun `an issuer in the label is used when there is no issuer parameter`() { + val config = OtpAuthUri.parse("otpauth://totp/GitHub:alice?secret=GEZDGNBVGY3TQOJQ")!! + assertThat(config.issuer).isEqualTo("GitHub") + assertThat(config.account).isEqualTo("alice") + } + + @Test + fun `hotp, a missing secret and plain text are all rejected`() { + // HOTP counts events rather than time; accepting it would generate codes that never + // verify, with nothing to tell the user why. + assertThat(OtpAuthUri.parse("otpauth://hotp/alice?secret=GEZDGNBVGY3TQOJQ&counter=1")) + .isNull() + assertThat(OtpAuthUri.parse("otpauth://totp/alice?issuer=ACME")).isNull() + assertThat(OtpAuthUri.parse("GEZDGNBVGY3TQOJQ")).isNull() + assertThat(OtpAuthUri.parse("")).isNull() + } + + @Test + fun `a uri and its hand-typed secret produce the same code`() { + val fromUri = OtpAuthUri.parse("otpauth://totp/alice?secret=GEZDGNBVGY3TQOJQ")!! + val fromSecret = TotpConfig(secret = Base32.decode("GEZDGNBVGY3TQOJQ")!!) + assertThat(TotpGenerator.generate(fromUri, 1111111109L)) + .isEqualTo(TotpGenerator.generate(fromSecret, 1111111109L)) + } +} From dcc0499531b848e75b031c906a84538a3709efda Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:26:31 +0530 Subject: [PATCH 22/25] Show authenticator codes and password history on the detail screen Authenticators appear above the entry list with their code, the label they came with, and a ring counting down to the next one; tapping copies the code as sensitive, so Android redacts the paste preview and the clipboard clears itself. The per-second clock lives in the card rather than in DetailState: a code is derived from the secret and the time, not state anyone edits, and a per-second emission in the screen's state would recompose the whole entry list once a second for a value only one row shows. Adding one takes either the otpauth:// link behind a QR code or the Base32 secret printed beside it. There is no scanner, deliberately: that means a camera permission in an app whose proposition is that it asks for almost none, and every setup page showing a QR code offers the same secret as text. History is offered only on rows the app knows are secrets, which is exactly where the repository writes it. It is read-only and each row copies rather than restores - a password that was replaced was usually replaced for a reason, and putting it back with one tap is the wrong default. Copying still covers the case that happens in practice, a site that has not caught up. Seven more ViewModel tests, including that a non-secret detail records no history: keeping every version of a username is storing plaintext nobody asked to keep. 110 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) --- .../screens/detail_screen/DetailContract.kt | 25 +++- .../screens/detail_screen/DetailPage.kt | 69 +++++++-- .../screens/detail_screen/DetailViewModel.kt | 75 +++++++++- .../components/AuthenticatorCard.kt | 139 ++++++++++++++++++ .../components/AuthenticatorDialog.kt | 75 ++++++++++ .../detail_screen/components/DetailsItem.kt | 18 +++ .../components/PasswordHistorySheet.kt | 119 +++++++++++++++ app/src/main/res/values-ar/strings.xml | 7 + app/src/main/res/values-bn/strings.xml | 7 + app/src/main/res/values-de/strings.xml | 7 + app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values-fr/strings.xml | 7 + app/src/main/res/values-gu/strings.xml | 7 + app/src/main/res/values-hi/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-ja/strings.xml | 7 + app/src/main/res/values-ko/strings.xml | 7 + app/src/main/res/values-mr/strings.xml | 7 + app/src/main/res/values-pt/strings.xml | 7 + app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values-ta/strings.xml | 7 + app/src/main/res/values-te/strings.xml | 7 + app/src/main/res/values-zh/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../bhardwaj/passkey/DetailViewModelTest.kt | 129 ++++++++++++++++ 25 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorCard.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorDialog.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt index bab67b2..69966cf 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailContract.kt @@ -2,7 +2,9 @@ package com.bhardwaj.passkey.presentation.screens.detail_screen import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.domain.model.PasswordCharacterClass +import com.bhardwaj.passkey.domain.model.PasswordHistoryEntry import com.bhardwaj.passkey.domain.model.PasswordPolicy +import com.bhardwaj.passkey.domain.model.TotpEntry import com.bhardwaj.passkey.utils.UiText data class DetailState( @@ -14,8 +16,16 @@ data class DetailState( /** Non-null means the delete confirmation is showing; the row is hidden but not deleted. */ val pendingDelete: Detail? = null, val policy: PasswordPolicy = PasswordPolicy(), - val isPolicySheetOpen: Boolean = false + val isPolicySheetOpen: Boolean = false, + val authenticators: List = emptyList(), + /** Non-null means the add-authenticator dialog is open. */ + val totpEditor: TotpEditor? = null, + /** Non-null means the history sheet is showing this secret's past values. */ + val history: History? = null ) { + data class TotpEditor(val input: String = "", val isInvalid: Boolean = false) + + data class History(val detail: Detail, val entries: List) data class Editor( val question: String = "", val answer: String = "", @@ -60,6 +70,19 @@ sealed interface DetailIntent { val enabled: Boolean ) : DetailIntent data object GenerateClicked : DetailIntent + + // Authenticator codes + data object AddAuthenticatorClicked : DetailIntent + data class AuthenticatorInputChanged(val input: String) : DetailIntent + data object AuthenticatorSaveClicked : DetailIntent + data object AuthenticatorEditorDismissed : DetailIntent + data class AuthenticatorDeleteClicked(val entry: TotpEntry) : DetailIntent + /** The code is generated in the UI, which is where the per-second clock lives. */ + data class AuthenticatorCodeCopied(val code: String) : DetailIntent + + // Password history + data class HistoryClicked(val detail: Detail) : DetailIntent + data object HistoryDismissed : DetailIntent } sealed interface DetailEffect { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt index 1433fba..48848c9 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailPage.kt @@ -18,9 +18,11 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -54,6 +56,9 @@ import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PasskeySearchBar import com.bhardwaj.passkey.presentation.screens.detail_screen.components.DetailsBottomSheet import com.bhardwaj.passkey.presentation.screens.detail_screen.components.DetailsItem +import com.bhardwaj.passkey.presentation.screens.detail_screen.components.AuthenticatorCard +import com.bhardwaj.passkey.presentation.screens.detail_screen.components.AuthenticatorDialog +import com.bhardwaj.passkey.presentation.screens.detail_screen.components.PasswordHistorySheet import com.bhardwaj.passkey.presentation.screens.detail_screen.components.PasswordSettingsSheet import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.utils.ButtonType @@ -119,19 +124,34 @@ fun DetailScreen( Scaffold( snackbarHost = { SnackbarHost(snackBarHostState) }, floatingActionButton = { - FloatingActionButton( - onClick = { - onIntent(DetailIntent.AddClicked) - }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - content = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.cd_add_detail), - ) - }, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SmallFloatingActionButton( + modifier = Modifier.padding(bottom = 12.dp), + onClick = { onIntent(DetailIntent.AddAuthenticatorClicked) }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.primary, + content = { + Icon( + imageVector = Icons.Default.Timer, + contentDescription = stringResource(R.string.add_authenticator), + ) + }, + ) + FloatingActionButton( + onClick = { + onIntent(DetailIntent.AddClicked) + }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + content = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_add_detail), + ) + }, + ) + } } ) { paddingValues -> Box( @@ -154,6 +174,14 @@ fun DetailScreen( color = MaterialTheme.colorScheme.secondary ) + state.authenticators.forEach { entry -> + AuthenticatorCard( + entry = entry, + onCopy = { onIntent(DetailIntent.AuthenticatorCodeCopied(it)) }, + onDelete = { onIntent(DetailIntent.AuthenticatorDeleteClicked(entry)) } + ) + } + if (state.query.isNotBlank() || state.items.isNotEmpty()) { PasskeySearchBar( modifier = Modifier @@ -267,6 +295,21 @@ fun DetailScreen( ) } } + state.totpEditor?.let { editor -> + AuthenticatorDialog( + editor = editor, + onInputChange = { onIntent(DetailIntent.AuthenticatorInputChanged(it)) }, + onDismiss = { onIntent(DetailIntent.AuthenticatorEditorDismissed) }, + onSave = { onIntent(DetailIntent.AuthenticatorSaveClicked) } + ) + } + state.history?.let { history -> + PasswordHistorySheet( + history = history, + onCopy = { onIntent(DetailIntent.LongPressed(it)) }, + onDismiss = { onIntent(DetailIntent.HistoryDismissed) } + ) + } if (state.isPolicySheetOpen) { PasswordSettingsSheet( policy = state.policy, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt index bf7619b..128357b 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/DetailViewModel.kt @@ -7,6 +7,10 @@ import androidx.navigation.toRoute import com.bhardwaj.passkey.R import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.domain.model.PasswordPolicy +import com.bhardwaj.passkey.domain.model.TotpEntry +import com.bhardwaj.passkey.domain.totp.Base32 +import com.bhardwaj.passkey.domain.totp.OtpAuthUri +import com.bhardwaj.passkey.domain.totp.TotpConfig import com.bhardwaj.passkey.domain.repository.PasskeyRepository import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.utils.PasswordGenerator @@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -49,14 +54,17 @@ class DetailViewModel @Inject constructor( val editor: DetailState.Editor? = null, val pendingDelete: Detail? = null, val policy: PasswordPolicy = PasswordPolicy(), - val isPolicySheetOpen: Boolean = false + val isPolicySheetOpen: Boolean = false, + val totpEditor: DetailState.TotpEditor? = null, + val history: DetailState.History? = null ) val state: StateFlow = combine( query, transient, - repository.getDetailsByPreviewId(previewId) - ) { currentQuery, ui, items -> + repository.getDetailsByPreviewId(previewId), + repository.getTotpByPreviewId(previewId) + ) { currentQuery, ui, items, authenticators -> DetailState( query = currentQuery, items = items @@ -70,7 +78,10 @@ class DetailViewModel @Inject constructor( editor = ui.editor, pendingDelete = ui.pendingDelete, policy = ui.policy, - isPolicySheetOpen = ui.isPolicySheetOpen + isPolicySheetOpen = ui.isPolicySheetOpen, + authenticators = authenticators, + totpEditor = ui.totpEditor, + history = ui.history ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DetailState()) @@ -150,6 +161,34 @@ class DetailViewModel @Inject constructor( it.copy(policy = it.policy.toggle(intent.characterClass, intent.enabled)) } + DetailIntent.AddAuthenticatorClicked -> + transient.update { it.copy(totpEditor = DetailState.TotpEditor()) } + + is DetailIntent.AuthenticatorInputChanged -> transient.update { + it.copy(totpEditor = it.totpEditor?.copy(input = intent.input, isInvalid = false)) + } + + DetailIntent.AuthenticatorEditorDismissed -> + transient.update { it.copy(totpEditor = null) } + + DetailIntent.AuthenticatorSaveClicked -> addAuthenticator() + + is DetailIntent.AuthenticatorDeleteClicked -> viewModelScope.launch { + repository.deleteTotp(intent.entry) + } + + is DetailIntent.AuthenticatorCodeCopied -> + emit(DetailEffect.CopyToClipboard(intent.code, isSensitive = true)) + + is DetailIntent.HistoryClicked -> viewModelScope.launch { + val entries = repository.getHistoryForDetail(intent.detail.id).first() + transient.update { + it.copy(history = DetailState.History(intent.detail, entries)) + } + } + + DetailIntent.HistoryDismissed -> transient.update { it.copy(history = null) } + DetailIntent.GenerateClicked -> transient.update { val policy = it.policy val generated = PasswordGenerator.generate( @@ -167,6 +206,29 @@ class DetailViewModel @Inject constructor( } } + /** + * Accepts either the `otpauth://` link behind a QR code or the Base32 secret printed beside + * it, because those are the two things a setup page actually offers. + */ + private fun addAuthenticator() { + val input = transient.value.totpEditor?.input?.trim().orEmpty() + val config = OtpAuthUri.parse(input) + ?: Base32.decode(input)?.let { TotpConfig(secret = it) } + + if (config == null) { + transient.update { it.copy(totpEditor = it.totpEditor?.copy(isInvalid = true)) } + return + } + + viewModelScope.launch { + val label = config.issuer?.takeIf { it.isNotBlank() } + ?: config.account?.takeIf { it.isNotBlank() } + ?: DEFAULT_AUTHENTICATOR_LABEL + repository.createTotp(previewId = previewId, label = label, config = config) + transient.update { it.copy(totpEditor = null) } + } + } + private fun save() { val editor = transient.value.editor ?: return val question = editor.question.trim() @@ -225,6 +287,11 @@ class DetailViewModel @Inject constructor( private fun emit(effect: DetailEffect) { viewModelScope.launch { _effects.send(effect) } } + + private companion object { + /** Only reached by a hand-typed secret, which carries no issuer or account. */ + const val DEFAULT_AUTHENTICATOR_LABEL = "Authenticator" + } } private inline fun MutableStateFlow.update(transform: (T) -> T) { diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorCard.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorCard.kt new file mode 100644 index 0000000..a9304d1 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorCard.kt @@ -0,0 +1,139 @@ +package com.bhardwaj.passkey.presentation.screens.detail_screen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.domain.model.TotpEntry +import com.bhardwaj.passkey.domain.totp.TotpGenerator +import com.bhardwaj.passkey.presentation.theme.BebasNeue +import com.bhardwaj.passkey.presentation.theme.Poppins +import kotlinx.coroutines.delay + +/** + * One authenticator, with its code and the time left before it rolls. + * + * The clock lives here rather than in the ViewModel on purpose: a code is derived from the + * secret and the time, not state anyone edits, and a per-second emission in DetailState would + * recompose the whole entry list once a second for a value only this row shows. + */ +@Composable +fun AuthenticatorCard( + modifier: Modifier = Modifier, + entry: TotpEntry, + onCopy: (String) -> Unit, + onDelete: () -> Unit +) { + val epochSeconds by produceState(initialValue = System.currentTimeMillis() / 1000) { + while (true) { + value = System.currentTimeMillis() / 1000 + delay(1_000) + } + } + + val code = remember(entry, epochSeconds / entry.config.periodSeconds) { + TotpGenerator.generate(entry.config, epochSeconds) + } + val remaining = TotpGenerator.secondsRemaining(entry.config, epochSeconds) + + Box(modifier = modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(10.dp) + ) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.background) + .clickable( + onClick = { onCopy(code) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1F)) { + Text( + // Grouped in halves, the way every authenticator shows a code, because it + // is read off the screen and typed somewhere else. + text = code.grouped(), + fontFamily = Poppins, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = entry.label, + fontFamily = Poppins, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.outline + ) + } + + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + progress = { remaining.toFloat() / entry.config.periodSeconds }, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.outlineVariant + ) + + Icon( + modifier = Modifier + .padding(start = 16.dp) + .clickable( + onClick = onDelete, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_remove_authenticator), + tint = MaterialTheme.colorScheme.primary + ) + } + Text( + modifier = Modifier + .padding(horizontal = 16.dp) + .background(MaterialTheme.colorScheme.background), + text = stringResource(R.string.authenticator), + fontFamily = BebasNeue, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onBackground + ) + } +} + +private fun String.grouped(): String { + val half = (length + 1) / 2 + return "${substring(0, half)} ${substring(half)}" +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorDialog.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorDialog.kt new file mode 100644 index 0000000..58af76c --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/AuthenticatorDialog.kt @@ -0,0 +1,75 @@ +package com.bhardwaj.passkey.presentation.screens.detail_screen.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailState +import com.bhardwaj.passkey.utils.ButtonType + +/** + * Takes either the `otpauth://` link behind a QR code or the Base32 secret printed beside it - + * the two things a two-factor setup page actually offers. + * + * No camera. Scanning the code itself would mean a camera permission in an app whose whole + * proposition is that it asks for almost none, and every setup page that shows a QR code also + * offers the same secret as text. + */ +@Composable +fun AuthenticatorDialog( + editor: DetailState.TotpEditor, + onInputChange: (String) -> Unit, + onDismiss: () -> Unit, + onSave: () -> Unit +) { + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.add_authenticator)) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = editor.input, + onValueChange = onInputChange, + isError = editor.isInvalid, + singleLine = false, + label = { Text(text = stringResource(R.string.authenticator_input_hint)) } + ) + if (editor.isInvalid) { + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource(R.string.authenticator_invalid), + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + PassKeyButton( + onClick = onSave, + text = stringResource(R.string.add), + buttonType = ButtonType.DEFAULT + ) + }, + dismissButton = { + PassKeyButton( + onClick = onDismiss, + text = stringResource(R.string.cancel), + buttonType = ButtonType.OUTLINED + ) + } + ) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt index 9270749..199e117 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsItem.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.History import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -107,6 +108,23 @@ fun DetailsItem( color = MaterialTheme.colorScheme.onBackground, ) + // History only exists for secrets, and only the repository writes it, so the icon + // appears exactly where there is something behind it. + if (scope != null && detail.isSecret) { + Icon( + modifier = Modifier + .padding(end = 12.dp) + .clickable( + onClick = { onIntent(DetailIntent.HistoryClicked(detail)) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + imageVector = Icons.Default.History, + contentDescription = stringResource(R.string.password_history), + tint = MaterialTheme.colorScheme.primary + ) + } + if (scope != null) { Icon( modifier = Modifier.clickable( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt new file mode 100644 index 0000000..d4cdfce --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt @@ -0,0 +1,119 @@ +package com.bhardwaj.passkey.presentation.screens.detail_screen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.SecureFlagPolicy +import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailState +import com.bhardwaj.passkey.presentation.theme.BebasNeue +import com.bhardwaj.passkey.presentation.theme.Poppins +import java.text.DateFormat +import java.util.Date + +/** + * The values a secret used to hold, newest first. + * + * Read-only, and each row copies rather than restores: a password that was replaced was usually + * replaced for a reason, and putting it back with one tap is the wrong default. Copying covers + * the case that actually happens - a site that has not caught up with the change yet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PasswordHistorySheet( + history: DetailState.History, + onCopy: (String) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) } + + ModalBottomSheet( + properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn), + sheetState = sheetState, + onDismissRequest = onDismiss, + dragHandle = {}, + shape = RectangleShape + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(24.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource(R.string.password_history), + fontFamily = BebasNeue, + fontSize = 32.sp, + color = MaterialTheme.colorScheme.secondary + ) + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = history.detail.question, + fontFamily = Poppins, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.outline + ) + + if (history.entries.isEmpty()) { + Text( + text = stringResource(R.string.password_history_empty), + fontFamily = Poppins, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onBackground + ) + } else { + LazyColumn { + items(history.entries, key = { it.id }) { entry -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = { onCopy(entry.answer) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(vertical = 12.dp) + ) { + Text( + text = entry.answer, + fontFamily = Poppins, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = dateFormat.format(Date(entry.changedAt)), + fontFamily = Poppins, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.outline + ) + } + HorizontalDivider() + } + } + } + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8f5bf02..cb776ee 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -172,4 +172,11 @@ تعذر حفظ بيانات تسجيل الدخول. افتح قفل PassKey لحفظ بيانات تسجيل الدخول. الملء التلقائي غير متوفر على هذا الجهاز. + المصادقة + إضافة مصادقة + الصق رابط otpauth:// أو اكتب المفتاح السري + هذا ليس مفتاح مصادقة صالحًا. + سجل كلمات المرور + لم يتم تغيير كلمة المرور هذه بعد. + إزالة المصادقة \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 50e17c0..3354cd2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -168,4 +168,11 @@ এই লগইন সংরক্ষণ করা যায়নি। এই লগইন সংরক্ষণ করতে PassKey আনলক করুন। এই ডিভাইসে স্বয়ংক্রিয় পূরণ উপলব্ধ নেই। + প্রমাণীকরণকারী + প্রমাণীকরণকারী যোগ করুন + otpauth:// লিঙ্ক পেস্ট করুন বা গোপন কী টাইপ করুন + এটি একটি বৈধ প্রমাণীকরণ কী নয়। + পাসওয়ার্ডের ইতিহাস + এই পাসওয়ার্ডটি এখনও পরিবর্তন করা হয়নি। + প্রমাণীকরণকারী সরান diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e58d70c..24fbb15 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -169,4 +169,11 @@ Diese Anmeldedaten konnten nicht gespeichert werden. Entsperre PassKey, um diese Anmeldedaten zu speichern. Automatisches Ausfüllen ist auf diesem Gerät nicht verfügbar. + Authenticator + Authenticator hinzufügen + otpauth://-Link einfügen oder geheimen Schlüssel eingeben + Das ist kein gültiger Authenticator-Schlüssel. + Passwortverlauf + Dieses Passwort wurde noch nicht geändert. + Authenticator entfernen \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d23e0e2..5608d0f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -169,4 +169,11 @@ No se pudo guardar este inicio de sesión. Desbloquea PassKey para guardar este inicio de sesión. Autocompletar no está disponible en este dispositivo. + Autenticador + Añadir autenticador + Pega un enlace otpauth:// o escribe la clave secreta + Esa no es una clave de autenticador válida. + Historial de contraseñas + Esta contraseña aún no se ha cambiado. + Quitar autenticador diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0976acb..c2b0cc5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -169,4 +169,11 @@ Impossible d\'enregistrer ces identifiants. Déverrouille PassKey pour enregistrer ces identifiants. La saisie automatique n\'est pas disponible sur cet appareil. + Authentificateur + Ajouter un authentificateur + Colle un lien otpauth:// ou saisis la clé secrète + Cette clé d\'authentificateur n\'est pas valide. + Historique des mots de passe + Ce mot de passe n\'a pas encore été modifié. + Supprimer l\'authentificateur diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index f0a8593..4090b7a 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -167,4 +167,11 @@ આ લૉગિન સાચવી શકાયું નહીં. આ લૉગિન સાચવવા માટે PassKey અનલૉક કરો. આ ઉપકરણ પર ઑટોફિલ ઉપલબ્ધ નથી. + ઑથેન્ટિકેટર + ઑથેન્ટિકેટર ઉમેરો + otpauth:// લિંક પેસ્ટ કરો અથવા ગુપ્ત કી લખો + આ માન્ય ઑથેન્ટિકેટર કી નથી. + પાસવર્ડ ઇતિહાસ + આ પાસવર્ડ હજી બદલાયો નથી. + ઑથેન્ટિકેટર દૂર કરો diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index f94c7fb..d0453ca 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -168,4 +168,11 @@ यह लॉगिन सहेजा नहीं जा सका। यह लॉगिन सहेजने के लिए PassKey अनलॉक करें। इस डिवाइस पर ऑटोफ़िल उपलब्ध नहीं है। + ऑथेंटिकेटर + ऑथेंटिकेटर जोड़ें + otpauth:// लिंक पेस्ट करें या गुप्त कुंजी लिखें + यह मान्य ऑथेंटिकेटर कुंजी नहीं है। + पासवर्ड इतिहास + यह पासवर्ड अभी तक बदला नहीं गया है। + ऑथेंटिकेटर हटाएँ \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a30b59b..bd12671 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -169,4 +169,11 @@ Impossibile salvare queste credenziali. Sblocca PassKey per salvare queste credenziali. La compilazione automatica non è disponibile su questo dispositivo. + Autenticatore + Aggiungi autenticatore + Incolla un link otpauth:// o digita la chiave segreta + Questa non è una chiave di autenticazione valida. + Cronologia password + Questa password non è ancora stata cambiata. + Rimuovi autenticatore diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5068828..d0616e9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -166,4 +166,11 @@ このログイン情報を保存できませんでした。 このログイン情報を保存するには PassKey のロックを解除してください。 このデバイスでは自動入力を利用できません。 + 認証アプリ + 認証アプリを追加 + otpauth:// リンクを貼り付けるか、秘密鍵を入力してください + 有効な認証キーではありません。 + パスワード履歴 + このパスワードはまだ変更されていません。 + 認証アプリを削除 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 92c0909..00f53bb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -167,4 +167,11 @@ 이 로그인 정보를 저장하지 못했습니다. 이 로그인 정보를 저장하려면 PassKey의 잠금을 해제하세요. 이 기기에서는 자동 완성을 사용할 수 없습니다. + 인증기 + 인증기 추가 + otpauth:// 링크를 붙여넣거나 비밀 키를 입력하세요 + 유효한 인증 키가 아닙니다. + 비밀번호 기록 + 이 비밀번호는 아직 변경되지 않았습니다. + 인증기 제거 \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index ad76945..0508606 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -168,4 +168,11 @@ हे लॉगिन जतन करता आले नाही. हे लॉगिन जतन करण्यासाठी PassKey अनलॉक करा. या डिव्हाइसवर ऑटोफिल उपलब्ध नाही. + ऑथेंटिकेटर + ऑथेंटिकेटर जोडा + otpauth:// दुवा पेस्ट करा किंवा गुप्त की टाइप करा + ही वैध ऑथेंटिकेटर की नाही. + पासवर्ड इतिहास + हा पासवर्ड अद्याप बदललेला नाही. + ऑथेंटिकेटर काढा \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 02f4ec9..1b14944 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -169,4 +169,11 @@ Não foi possível salvar este login. Desbloqueie o PassKey para salvar este login. O preenchimento automático não está disponível neste dispositivo. + Autenticador + Adicionar autenticador + Cole um link otpauth:// ou digite a chave secreta + Essa não é uma chave de autenticador válida. + Histórico de senhas + Esta senha ainda não foi alterada. + Remover autenticador \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b119c46..35776ea 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -169,4 +169,11 @@ Не удалось сохранить эти данные для входа. Разблокируйте PassKey, чтобы сохранить эти данные для входа. Автозаполнение недоступно на этом устройстве. + Аутентификатор + Добавить аутентификатор + Вставьте ссылку otpauth:// или введите секретный ключ + Это недействительный ключ аутентификатора. + История паролей + Этот пароль ещё не менялся. + Удалить аутентификатор diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 86d02a7..4d99146 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -167,4 +167,11 @@ இந்த உள்நுழைவைச் சேமிக்க முடியவில்லை. இந்த உள்நுழைவைச் சேமிக்க PassKey ஐத் திறக்கவும். இந்தச் சாதனத்தில் தானியங்கு நிரப்புதல் கிடைக்கவில்லை. + அங்கீகரிப்பான் + அங்கீகரிப்பானைச் சேர் + otpauth:// இணைப்பை ஒட்டவும் அல்லது ரகசியச் சாவியை உள்ளிடவும் + இது செல்லுபடியாகும் அங்கீகரிப்புச் சாவி அல்ல. + கடவுச்சொல் வரலாறு + இந்தக் கடவுச்சொல் இதுவரை மாற்றப்படவில்லை. + அங்கீகரிப்பானை நீக்கு diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 13e088e..3cc7a88 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -167,5 +167,12 @@ ఈ లాగిన్‌ను సేవ్ చేయలేకపోయాం. ఈ లాగిన్‌ను సేవ్ చేయడానికి PassKey అన్‌లాక్ చేయండి. ఈ పరికరంలో ఆటోఫిల్ అందుబాటులో లేదు. + ప్రామాణీకరణి + ప్రామాణీకరణిని జోడించు + otpauth:// లింక్‌ను అతికించండి లేదా రహస్య కీని టైప్ చేయండి + ఇది చెల్లుబాటు అయ్యే ప్రామాణీకరణ కీ కాదు. + పాస్‌వర్డ్ చరిత్ర + ఈ పాస్‌వర్డ్ ఇంకా మార్చబడలేదు. + ప్రామాణీకరణిని తీసివేయి diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a6f5efd..6b10cc8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -167,4 +167,11 @@ 无法保存此登录信息。 请解锁 PassKey 以保存此登录信息。 此设备不支持自动填充。 + 身份验证器 + 添加身份验证器 + 粘贴 otpauth:// 链接或输入密钥 + 这不是有效的身份验证密钥。 + 密码历史 + 此密码尚未更改过。 + 移除身份验证器 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bdecae..4fb2ee2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,4 +242,11 @@ Questions: contact yrkkh.cclub@gm Could not save this login. Unlock PassKey to save this login. Autofill is not available on this device. + Authenticator + Add authenticator + Paste an otpauth:// link or type the secret key + That is not a valid authenticator key. + Password history + This password has not been changed yet. + Remove authenticator \ No newline at end of file diff --git a/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt b/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt index b768614..72444a4 100644 --- a/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt +++ b/app/src/test/java/com/bhardwaj/passkey/DetailViewModelTest.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.domain.model.PasswordCharacterClass +import com.bhardwaj.passkey.domain.totp.Base32 +import com.bhardwaj.passkey.domain.totp.TotpAlgorithm import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailEffect import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailIntent import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailViewModel @@ -267,4 +269,131 @@ class DetailViewModelTest { val ordered: List = repository.currentDetails().sortedBy { it.sequence } assertThat(ordered.map { it.question }).containsExactly("B", "C", "A").inOrder() } + + @Test + fun `an otpauth link becomes an authenticator with its own parameters`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddAuthenticatorClicked) + vm.onIntent( + DetailIntent.AuthenticatorInputChanged( + "otpauth://totp/GitHub:alice?secret=GEZDGNBVGY3TQOJQ&algorithm=SHA256&digits=8" + ) + ) + vm.onIntent(DetailIntent.AuthenticatorSaveClicked) + assertThat(expectMostRecentItem().totpEditor).isNull() + cancelAndIgnoreRemainingEvents() + } + + val entry = repository.currentTotps().single() + assertThat(entry.label).isEqualTo("GitHub") + assertThat(entry.config.algorithm).isEqualTo(TotpAlgorithm.SHA256) + assertThat(entry.config.digits).isEqualTo(8) + assertThat(entry.config.secret).isEqualTo(Base32.decode("GEZDGNBVGY3TQOJQ")) + } + + @Test + fun `a bare secret typed by hand is accepted too`() = runTest(dispatcher) { + // Every setup page that shows a QR code also prints this, which is why the app needs no + // camera permission to add an authenticator. + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddAuthenticatorClicked) + vm.onIntent(DetailIntent.AuthenticatorInputChanged("gezd gnbv gy3t qojq")) + vm.onIntent(DetailIntent.AuthenticatorSaveClicked) + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.currentTotps()).hasSize(1) + } + + @Test + fun `invalid input marks the dialog rather than storing an unusable secret`() = + runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddAuthenticatorClicked) + vm.onIntent(DetailIntent.AuthenticatorInputChanged("not a key!")) + vm.onIntent(DetailIntent.AuthenticatorSaveClicked) + + val editor = expectMostRecentItem().totpEditor + assertThat(editor).isNotNull() + assertThat(editor!!.isInvalid).isTrue() + cancelAndIgnoreRemainingEvents() + } + assertThat(repository.currentTotps()).isEmpty() + } + + @Test + fun `typing again clears the error so the user is not told off while correcting it`() = + runTest(dispatcher) { + val vm = viewModel() + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.AddAuthenticatorClicked) + vm.onIntent(DetailIntent.AuthenticatorInputChanged("nope!")) + vm.onIntent(DetailIntent.AuthenticatorSaveClicked) + vm.onIntent(DetailIntent.AuthenticatorInputChanged("GEZDGNBVGY3TQOJQ")) + assertThat(expectMostRecentItem().totpEditor?.isInvalid).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `copying a code marks it sensitive`() = runTest(dispatcher) { + val vm = viewModel() + vm.state.test { awaitItem(); cancelAndIgnoreRemainingEvents() } + vm.effects.test { + vm.onIntent(DetailIntent.AuthenticatorCodeCopied("123456")) + assertThat(awaitItem()) + .isEqualTo(DetailEffect.CopyToClipboard("123456", isSensitive = true)) + } + } + + @Test + fun `editing a secret records what it used to be`() = runTest(dispatcher) { + val id = repository.createDetail( + previewId = PREVIEW_ID, question = "Password", answer = "old", isSecret = true + ) + val vm = viewModel() + + vm.state.test { + awaitItem() + val row = repository.currentDetails().single { it.id == id } + vm.onIntent(DetailIntent.EditClicked(row)) + vm.onIntent(DetailIntent.AnswerChanged("new")) + vm.onIntent(DetailIntent.SaveClicked) + expectMostRecentItem() + + vm.onIntent(DetailIntent.HistoryClicked(repository.currentDetails().single())) + val history = expectMostRecentItem().history + assertThat(history).isNotNull() + assertThat(history!!.entries.map { it.answer }).containsExactly("old") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a non-secret detail records no history`() = runTest(dispatcher) { + // Usernames and notes change without consequence; keeping every version of one is + // storing plaintext nobody asked to keep. + val id = repository.createDetail( + previewId = PREVIEW_ID, question = "Username", answer = "alice" + ) + val vm = viewModel() + + vm.state.test { + awaitItem() + vm.onIntent(DetailIntent.EditClicked(repository.currentDetails().single { it.id == id })) + vm.onIntent(DetailIntent.AnswerChanged("bob")) + vm.onIntent(DetailIntent.SaveClicked) + expectMostRecentItem() + + vm.onIntent(DetailIntent.HistoryClicked(repository.currentDetails().single())) + assertThat(expectMostRecentItem().history?.entries).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } } From 3feda80af29f662cdb04000257e6462134aa6a04 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:29:09 +0530 Subject: [PATCH 23/25] Bring the docs and the About text up to what 5.8.0 actually does Version 5.8.0, code 47. The README still carried the 5.7.0 "known limitation" notice saying the database key is a build-time constant. That shipped; the section now describes the three-slot key hierarchy that replaced it, and the one-time re-key of older vaults. The in-app About text and the README feature list gain autofill, authenticator codes and password history. All 16 translations of the About text were updated in the same pass - a base string edited without them is the stale-copy bug this release set out to fix, and MissingTranslation cannot see it because the translations exist, they are just behind. The privacy policy gains an Autofill section. It is the one feature that makes Android show the app something from outside it - the structure of the form being filled - so it says plainly that this happens on the device and that a locked vault offers no entry names at all. Co-Authored-By: Claude Opus 5 (1M context) --- Privacy Policy.md | 15 +++++++++++++++ README.md | 25 ++++++++++++++++++------- app/build.gradle.kts | 4 ++-- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-bn/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-gu/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-mr/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-ta/strings.xml | 2 +- app/src/main/res/values-te/strings.xml | 2 +- app/src/main/res/values-zh/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 20 files changed, 52 insertions(+), 26 deletions(-) diff --git a/Privacy Policy.md b/Privacy Policy.md index 3466546..8ea5a51 100644 --- a/Privacy Policy.md +++ b/Privacy Policy.md @@ -33,6 +33,21 @@ None of these components has access to your vault contents. Data handled by these services is processed by Google under [Google's privacy policy](https://policies.google.com/privacy). +## Autofill + +If you make PassKey your autofill service, Android shows it the structure of the form you are +filling - the field types and the app package or web address it belongs to - so that it can +offer matching entries. That happens entirely on the device: nothing about the form, the site or +the entries offered is transmitted, logged or stored. + +While the vault is locked, PassKey offers a single "unlock" suggestion and no entry names, so the +suggestion list drawn over another app never reveals which accounts you hold. Saving a login that +autofill captured only works while the vault is unlocked; otherwise the app says so rather than +keeping the credential anywhere. + +Autofill is off until you turn it on in Android's settings, and it can be turned off there again +at any time. + ## Backups When you export a backup, the file is encrypted with AES-256-GCM using a key derived from a diff --git a/README.md b/README.md index 281b932..1791334 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,11 @@ design -> https://www.figma.com/community/file/1116675775484733517 - Biometric / device-credential unlock, with automatic re-lock on background and idle. - Screenshot blocking, including dialogs and bottom sheets. - Clipboard entries marked sensitive and cleared automatically. +- Autofill service: fills usernames and passwords into other apps and web pages, and offers to + save new logins. +- Built-in authenticator: RFC 6238 time-based codes, added from an `otpauth://` link or a typed + secret. No camera permission. +- Password history per entry, so a change can be traced or copied back. - Password generator backed by SecureRandom. - Vault analysis for weak and reused passwords. - Password-protected encrypted backup and restore, written wherever you choose. @@ -53,19 +58,25 @@ design -> https://www.figma.com/community/file/1116675775484733517 (PBKDF2-HMAC-SHA512), written through the system file picker. - The app performs no network requests of its own and has no server or account. -> **Known limitation, being addressed in 5.7.0:** the database key is currently a single -> build-time constant shared by every install, so the database is encrypted against someone who -> obtains the file alone, and not against someone who also has the APK. 5.7.0 replaces it with a -> per-install random key wrapped by an Android Keystore key and unlocked via a biometric -> `CryptoObject`, with a password-based recovery slot. +- The database key is a per-install random key, never a build-time constant. It is wrapped three + independent ways - by a biometric-bound Keystore key unlocked through a `CryptoObject`, by a + device-credential-bound one, and by a recovery password - so losing any one of them, including + by re-enrolling a fingerprint, does not lose the vault. +- Vaults created before 5.7.0 are re-keyed once, on first launch, by exporting through + `sqlcipher_export()` into a sidecar file that only replaces the original after it verifies. +- Autofill never reveals entry names before the vault is unlocked: a locked vault offers a single + "unlock" suggestion and nothing else. ## 🌐 Network and privacy: The app itself makes no network calls, but it bundles Google Firebase (Crashlytics, Analytics, Performance Monitoring) and Google Play in-app updates. These require the `INTERNET` permission and send crash diagnostics, usage events, performance traces and device/installation identifiers -— including an advertising ID — to Google. They never have access to vault contents. See -[Privacy Policy.md](Privacy%20Policy.md). +— including an advertising ID — to Google. They never have access to vault contents. + +Autofill runs entirely on the device: Android shows the app the structure of the form being +filled so it can offer matching entries, and nothing about that form or those entries is +transmitted or logged. See [Privacy Policy.md](Privacy%20Policy.md). ## 🌎 Released Android Application: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7740460..5f42cc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.bhardwaj.passkey" minSdk = 28 targetSdk = 36 - versionCode = 46 - versionName = "5.7.0" + versionCode = 47 + versionName = "5.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index cb776ee..a112e19 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -138,7 +138,7 @@ مفتاح تسجيل الدخول - يحفظ PassKey بيانات تسجيل الدخول وكلمات المرور والملاحظات الخاصة الأخرى في قاعدة بيانات مشفّرة على جهازك.\n\nخزنتك لا تغادر هاتفك أبدًا. لا يوجد حساب ولا مزامنة ولا خادم لـ PassKey: التطبيق لا يجري أي طلبات شبكة من تلقاء نفسه، ولا تُرسل مدخلاتك إلى أي مكان.\n\nقاعدة البيانات مشفّرة بواسطة SQLCipher وتُفتح ببصمتك أو قفل شاشة جهازك. تُقفل الخزنة تلقائيًا عند مغادرة التطبيق، ولقطات الشاشة محظورة، وكلمات المرور المنسوخة تُعلَّم كحساسة وتُمسح من الحافظة بعد وقت قصير.\n\nيمكنك تنظيم المدخلات في فئات، وإعادة ترتيبها بالسحب، وإنشاء كلمات مرور قوية، وفحص خزنتك بحثًا عن كلمات مرور ضعيفة أو مكررة. تُصدَّر النسخ الاحتياطية كملف واحد مشفّر بكلمة مرور تختارها، ويمكن استعادتها على أي جهاز.\n\nPassKey مجاني ومفتوح المصدر، طوّره Aditya Bhardwaj. الشيفرة المصدرية الكاملة متاحة على GitHub. + يحفظ PassKey بيانات تسجيل الدخول وكلمات المرور والملاحظات الخاصة الأخرى في قاعدة بيانات مشفّرة على جهازك.\n\nخزنتك لا تغادر هاتفك أبدًا. لا يوجد حساب ولا مزامنة ولا خادم لـ PassKey: التطبيق لا يجري أي طلبات شبكة من تلقاء نفسه، ولا تُرسل مدخلاتك إلى أي مكان.\n\nقاعدة البيانات مشفّرة بواسطة SQLCipher وتُفتح ببصمتك أو قفل شاشة جهازك. تُقفل الخزنة تلقائيًا عند مغادرة التطبيق، ولقطات الشاشة محظورة، وكلمات المرور المنسوخة تُعلَّم كحساسة وتُمسح من الحافظة بعد وقت قصير.\n\nيمكنك تنظيم المدخلات في فئات، وإعادة ترتيبها بالسحب، وإنشاء كلمات مرور قوية، وفحص خزنتك بحثًا عن كلمات مرور ضعيفة أو مكررة. تُصدَّر النسخ الاحتياطية كملف واحد مشفّر بكلمة مرور تختارها، ويمكن استعادتها على أي جهاز. يمكن لـ PassKey ملء بيانات تسجيل الدخول في التطبيقات والمواقع الأخرى، والاحتفاظ برموز المصادقة الثنائية، وحفظ سجل لكل كلمة مرور تغيّرها.\n\nPassKey مجاني ومفتوح المصدر، طوّره Aditya Bhardwaj. الشيفرة المصدرية الكاملة متاحة على GitHub. تبقى خزنتك على جهازك.\n\nما لا نجمعه أبدًا: لا يجمع PassKey ولا ينقل ولا يخزّن كلمات المرور أو أسماء المستخدمين أو الملاحظات أو أي مدخلات تحفظها. توجد فقط في قاعدة بيانات مشفّرة على هاتفك. لا يمكننا قراءتها ولا استعادتها لك.\n\nما يرسله التطبيق: يتضمن PassKey خدمات Google Firebase Crashlytics وAnalytics وPerformance Monitoring، وتحديثات Google Play داخل التطبيق. ترسل هذه الخدمات إلى Google تشخيصات الأعطال وأحداث الاستخدام وبيانات الأداء ومعرّفات الجهاز والتثبيت بما في ذلك معرّف إعلاني. لا تصل هذه الخدمات إلى محتوى خزنتك إطلاقًا. بسبب هذه الخدمات يحتاج التطبيق إلى إذن الإنترنت، وتخضع معالجتها للبيانات لسياسة خصوصية Google.\n\nالنسخ الاحتياطية: عند التصدير يُشفَّر الملف بكلمة مرور تختارها ويُحفظ حيث تحدد للنظام. تصبح مسؤولية هذا الملف عليك بعد ذلك؛ من يحصل على الملف وكلمة مروره معًا يستطيع قراءته. نحن لا نحتفظ بأي نسخة.\n\nنسخ الجهاز الاحتياطي: PassKey مستثنى من النسخ الاحتياطي السحابي في Android ومن النقل بين الأجهزة، لذا لا تُرفع خزنتك إلى حساب Google الخاص بك.\n\nالتغييرات: قد تُحدَّث هذه السياسة؛ وستُذكر التغييرات الجوهرية في ملاحظات إصدار التطبيق.\n\nللاستفسار: تواصل عبر yrkkh.cclub@gmail.com. باستخدامك PassKey فإنك توافق على ما يلي.\n\n1. PassKey برنامج مجاني مفتوح المصدر طوّره Aditya Bhardwaj.\n\n2. تُخزَّن خزنتك على جهازك فقط. لا توجد نسخة على أي خادم، وبالتالي لا توجد وسيلة لاستعادة بياناتك إذا فقدت جهازك أو أزلت التطبيق أو نسيت كلمة مرور النسخة الاحتياطية.\n\n3. الاحتفاظ بالنسخ الاحتياطية مسؤوليتك. صدّرها بانتظام واحفظ كلمة مرور النسخة في مكان آمن ومنفصل.\n\n4. يعتمد التطبيق على أمان البصمة أو قفل الشاشة في جهازك. إذا كان هذا القفل ضعيفًا أو مُزالًا أو مخترقًا، تقل الحماية التي يوفرها PassKey بالقدر نفسه.\n\n5. يتضمن PassKey خدمات Google Firebase وGoogle Play كما هو موضح في سياسة الخصوصية.\n\n6. يُقدَّم التطبيق \"كما هو\" دون أي ضمان صريح أو ضمني، بما في ذلك القابلية للتسويق والملاءمة لغرض معين.\n\n7. إلى الحد الذي يسمح به القانون، لا يتحمل المطوّر مسؤولية أي أضرار ناتجة عن استخدام التطبيق، بما في ذلك فقدان البيانات.\n\n8. يجوز تعديل التطبيق أو إيقافه في أي وقت. عيّن كلمة مرور للاسترداد diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 3354cd2..764d74b 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -134,7 +134,7 @@ চাবি লগইন - PassKey আপনার লগইন, পাসওয়ার্ড এবং অন্যান্য ব্যক্তিগত নোট আপনার ডিভাইসের একটি এনক্রিপ্ট করা ডেটাবেসে সংরক্ষণ করে।\n\nআপনার ভল্ট কখনও ফোন ছেড়ে যায় না। কোনো অ্যাকাউন্ট নেই, সিঙ্ক নেই, PassKey-এর কোনো সার্ভারও নেই: অ্যাপটি নিজে থেকে কোনো নেটওয়ার্ক অনুরোধ করে না এবং আপনার এন্ট্রি কোথাও পাঠানো হয় না।\n\nডেটাবেসটি SQLCipher দিয়ে এনক্রিপ্ট করা এবং আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন লক দিয়ে খোলে। অ্যাপ ছাড়লেই ভল্ট স্বয়ংক্রিয়ভাবে আবার লক হয়, স্ক্রিনশট বন্ধ, এবং কপি করা পাসওয়ার্ড সংবেদনশীল হিসেবে চিহ্নিত হয়ে অল্প পরেই ক্লিপবোর্ড থেকে মুছে যায়।\n\nআপনি এন্ট্রি বিভাগে সাজাতে, টেনে ক্রম বদলাতে, শক্তিশালী পাসওয়ার্ড তৈরি করতে এবং দুর্বল বা পুনরায় ব্যবহৃত পাসওয়ার্ড পরীক্ষা করতে পারেন। ব্যাকআপ একটি ফাইল হিসেবে রপ্তানি হয়, আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট করা, এবং যেকোনো ডিভাইসে পুনরুদ্ধার করা যায়।\n\nPassKey বিনামূল্যে ও ওপেন সোর্স, তৈরি করেছেন Aditya Bhardwaj। সম্পূর্ণ সোর্স কোড GitHub-এ পাওয়া যায়। + PassKey আপনার লগইন, পাসওয়ার্ড এবং অন্যান্য ব্যক্তিগত নোট আপনার ডিভাইসের একটি এনক্রিপ্ট করা ডেটাবেসে সংরক্ষণ করে।\n\nআপনার ভল্ট কখনও ফোন ছেড়ে যায় না। কোনো অ্যাকাউন্ট নেই, সিঙ্ক নেই, PassKey-এর কোনো সার্ভারও নেই: অ্যাপটি নিজে থেকে কোনো নেটওয়ার্ক অনুরোধ করে না এবং আপনার এন্ট্রি কোথাও পাঠানো হয় না।\n\nডেটাবেসটি SQLCipher দিয়ে এনক্রিপ্ট করা এবং আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন লক দিয়ে খোলে। অ্যাপ ছাড়লেই ভল্ট স্বয়ংক্রিয়ভাবে আবার লক হয়, স্ক্রিনশট বন্ধ, এবং কপি করা পাসওয়ার্ড সংবেদনশীল হিসেবে চিহ্নিত হয়ে অল্প পরেই ক্লিপবোর্ড থেকে মুছে যায়।\n\nআপনি এন্ট্রি বিভাগে সাজাতে, টেনে ক্রম বদলাতে, শক্তিশালী পাসওয়ার্ড তৈরি করতে এবং দুর্বল বা পুনরায় ব্যবহৃত পাসওয়ার্ড পরীক্ষা করতে পারেন। ব্যাকআপ একটি ফাইল হিসেবে রপ্তানি হয়, আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট করা, এবং যেকোনো ডিভাইসে পুনরুদ্ধার করা যায়। PassKey অন্যান্য অ্যাপ ও ওয়েবসাইটে আপনার লগইন পূরণ করতে পারে, আপনার দুই-ধাপের প্রমাণীকরণ কোড রাখতে পারে এবং আপনি যে পাসওয়ার্ড বদলান তার ইতিহাস রাখতে পারে।\n\nPassKey বিনামূল্যে ও ওপেন সোর্স, তৈরি করেছেন Aditya Bhardwaj। সম্পূর্ণ সোর্স কোড GitHub-এ পাওয়া যায়। আপনার ভল্ট আপনার ডিভাইসেই থাকে।\n\nআমরা যা কখনও সংগ্রহ করি না: PassKey আপনার সংরক্ষিত পাসওয়ার্ড, ব্যবহারকারীর নাম, নোট বা অন্য কোনো এন্ট্রি সংগ্রহ, প্রেরণ বা সঞ্চয় করে না। সেগুলি কেবল আপনার ফোনের এনক্রিপ্ট করা ডেটাবেসে থাকে। আমরা সেগুলি পড়তেও পারি না, আপনার জন্য ফিরিয়ে আনতেও পারি না।\n\nঅ্যাপ যা পাঠায়: PassKey-তে Google Firebase Crashlytics, Analytics ও Performance Monitoring এবং Google Play ইন-অ্যাপ আপডেট অন্তর্ভুক্ত। এগুলি ক্র্যাশ ডায়াগনস্টিক, ব্যবহারের ঘটনা, কর্মক্ষমতার তথ্য এবং বিজ্ঞাপন আইডিসহ ডিভাইস ও ইনস্টলেশন শনাক্তকারী Google-কে পাঠায়। আপনার ভল্টের বিষয়বস্তুতে এদের কখনও প্রবেশাধিকার নেই। এই পরিষেবাগুলির কারণে অ্যাপটির ইন্টারনেট অনুমতি প্রয়োজন, এবং তাদের তথ্য ব্যবস্থাপনা Google-এর গোপনীয়তা নীতি দ্বারা পরিচালিত।\n\nব্যাকআপ: রপ্তানির সময় ফাইলটি আপনার বাছাই করা পাসওয়ার্ড দিয়ে এনক্রিপ্ট হয় এবং আপনি সিস্টেমকে যেখানে বলবেন সেখানে সংরক্ষিত হয়। এরপর সেই ফাইলের দায়িত্ব আপনার; যার কাছে ফাইল ও পাসওয়ার্ড দুটোই থাকবে সে পড়তে পারবে। আমরা কোনো অনুলিপি রাখি না।\n\nডিভাইস ব্যাকআপ: PassKey Android ক্লাউড ব্যাকআপ ও ডিভাইস-টু-ডিভাইস স্থানান্তর থেকে বাদ, তাই আপনার ভল্ট আপনার Google অ্যাকাউন্টে আপলোড হয় না।\n\nপরিবর্তন: এই নীতি হালনাগাদ হতে পারে; গুরুত্বপূর্ণ পরিবর্তন অ্যাপের রিলিজ নোটে জানানো হবে।\n\nপ্রশ্ন: যোগাযোগ করুন yrkkh.cclub@gmail.com। PassKey ব্যবহার করে আপনি নিম্নলিখিত বিষয়ে সম্মত হচ্ছেন।\n\n১. PassKey একটি বিনামূল্যের, ওপেন-সোর্স সফটওয়্যার যা Aditya Bhardwaj তৈরি করেছেন।\n\n২. আপনার ভল্ট কেবল আপনার ডিভাইসে সংরক্ষিত। সার্ভারে কোনো অনুলিপি নেই, তাই ডিভাইস হারালে, অ্যাপ আনইনস্টল করলে বা ব্যাকআপ পাসওয়ার্ড ভুলে গেলে ডেটা ফেরানোর কোনো উপায় নেই।\n\n৩. ব্যাকআপ রাখা আপনার দায়িত্ব। নিয়মিত রপ্তানি করুন এবং ব্যাকআপ পাসওয়ার্ড নিরাপদ ও আলাদা জায়গায় রাখুন।\n\n৪. অ্যাপটি আপনার ডিভাইসের বায়োমেট্রিক বা স্ক্রিন-লক নিরাপত্তার উপর নির্ভরশীল। সেই লক দুর্বল, সরানো বা আপসকৃত হলে PassKey-এর সুরক্ষাও সেই অনুপাতে কমে যায়।\n\n৫. PassKey-তে গোপনীয়তা নীতিতে বর্ণিত Google Firebase ও Google Play পরিষেবা অন্তর্ভুক্ত।\n\n৬. অ্যাপ্লিকেশনটি \"যেমন আছে\" সরবরাহ করা হয়, কোনো প্রকার প্রকাশ্য বা অন্তর্নিহিত ওয়ারেন্টি ছাড়াই, যার মধ্যে বাণিজ্যিক উপযোগিতা ও নির্দিষ্ট উদ্দেশ্যের উপযুক্ততা অন্তর্ভুক্ত।\n\n৭. আইন অনুমোদিত সীমা পর্যন্ত, ডেভেলপার অ্যাপ্লিকেশন ব্যবহারের ফলে উদ্ভূত কোনো ক্ষতির জন্য, তথ্য হারানো সহ, দায়ী নন।\n\n৮. অ্যাপ্লিকেশনটি যেকোনো সময় পরিবর্তন বা বন্ধ করা হতে পারে। পুনরুদ্ধার পাসওয়ার্ড সেট করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 24fbb15..d6f4726 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -135,7 +135,7 @@ zugangsdaten anmeldung - PassKey speichert deine Zugangsdaten, Passwörter und privaten Notizen in einer verschlüsselten Datenbank auf deinem Gerät.\n\nDein Tresor verlässt dein Telefon nie. Es gibt kein Konto, keine Synchronisierung und keinen PassKey-Server: Die App stellt keine eigenen Netzwerkanfragen, und deine Einträge werden nirgendwohin übertragen.\n\nDie Datenbank ist mit SQLCipher verschlüsselt und wird mit der Biometrie oder Bildschirmsperre deines Geräts entsperrt. Der Tresor sperrt sich automatisch, wenn du die App verlässt, Screenshots sind blockiert, und kopierte Passwörter werden als vertraulich markiert und kurz darauf aus der Zwischenablage gelöscht.\n\nDu kannst Einträge in Kategorien ordnen, sie per Ziehen neu anordnen, starke Passwörter erzeugen und deinen Tresor auf schwache oder mehrfach verwendete Passwörter prüfen. Backups werden als einzelne Datei exportiert, verschlüsselt mit einem Passwort deiner Wahl, und lassen sich auf jedem Gerät wiederherstellen.\n\nPassKey ist kostenlos und quelloffen, entwickelt von Aditya Bhardwaj. Der vollständige Quellcode ist auf GitHub verfügbar. + PassKey speichert deine Zugangsdaten, Passwörter und privaten Notizen in einer verschlüsselten Datenbank auf deinem Gerät.\n\nDein Tresor verlässt dein Telefon nie. Es gibt kein Konto, keine Synchronisierung und keinen PassKey-Server: Die App stellt keine eigenen Netzwerkanfragen, und deine Einträge werden nirgendwohin übertragen.\n\nDie Datenbank ist mit SQLCipher verschlüsselt und wird mit der Biometrie oder Bildschirmsperre deines Geräts entsperrt. Der Tresor sperrt sich automatisch, wenn du die App verlässt, Screenshots sind blockiert, und kopierte Passwörter werden als vertraulich markiert und kurz darauf aus der Zwischenablage gelöscht.\n\nDu kannst Einträge in Kategorien ordnen, sie per Ziehen neu anordnen, starke Passwörter erzeugen und deinen Tresor auf schwache oder mehrfach verwendete Passwörter prüfen. Backups werden als einzelne Datei exportiert, verschlüsselt mit einem Passwort deiner Wahl, und lassen sich auf jedem Gerät wiederherstellen. PassKey kann deine Zugangsdaten in andere Apps und Websites eintragen, deine Zwei-Faktor-Codes verwahren und einen Verlauf jedes geänderten Passworts führen.\n\nPassKey ist kostenlos und quelloffen, entwickelt von Aditya Bhardwaj. Der vollständige Quellcode ist auf GitHub verfügbar. Dein Tresor bleibt auf deinem Gerät.\n\nWas wir nie erfassen: PassKey erfasst, überträgt oder speichert keine der Passwörter, Benutzernamen, Notizen oder sonstigen Einträge, die du sicherst. Sie liegen ausschließlich in einer verschlüsselten Datenbank auf deinem Telefon. Wir können sie weder lesen noch für dich wiederherstellen.\n\nWas die App sendet: PassKey enthält Google Firebase Crashlytics, Analytics und Performance Monitoring sowie Google Play In-App-Updates. Diese senden Absturzdiagnosen, Nutzungsereignisse, Leistungsdaten sowie Geräte- und Installationskennungen einschließlich einer Werbe-ID an Google. Sie haben nie Zugriff auf den Inhalt deines Tresors. Wegen dieser Dienste benötigt die App die Internetberechtigung; deren Datenverarbeitung unterliegt der Datenschutzerklärung von Google.\n\nBackups: Beim Export wird die Datei mit einem Passwort deiner Wahl verschlüsselt und dort abgelegt, wo du es dem System vorgibst. Diese Datei liegt dann in deiner Verantwortung; wer Datei und Passwort besitzt, kann sie lesen. Wir behalten keine Kopie.\n\nGerätesicherung: PassKey ist von der Android-Cloud-Sicherung und der Geräteübertragung ausgeschlossen, dein Tresor wird also nicht in dein Google-Konto hochgeladen.\n\nÄnderungen: Diese Erklärung kann aktualisiert werden; wesentliche Änderungen werden in den Versionshinweisen der App vermerkt.\n\nFragen: Kontakt yrkkh.cclub@gmail.com. Mit der Nutzung von PassKey stimmst du Folgendem zu.\n\n1. PassKey ist kostenlose, quelloffene Software, entwickelt von Aditya Bhardwaj.\n\n2. Dein Tresor wird nur auf deinem Gerät gespeichert. Es gibt keine serverseitige Kopie und damit keine Möglichkeit, deine Daten wiederherzustellen, wenn du dein Gerät verlierst, die App deinstallierst oder ein Backup-Passwort vergisst.\n\n3. Backups liegen in deiner Verantwortung. Exportiere sie regelmäßig und bewahre das Backup-Passwort sicher und getrennt auf.\n\n4. Die App stützt sich auf die biometrische Sicherung oder Bildschirmsperre deines Geräts. Ist diese schwach, entfernt oder kompromittiert, verringert sich der Schutz durch PassKey entsprechend.\n\n5. PassKey enthält Google-Firebase- und Google-Play-Dienste wie in der Datenschutzerklärung beschrieben.\n\n6. Die Anwendung wird \"wie besehen\" bereitgestellt, ohne jegliche ausdrückliche oder stillschweigende Gewährleistung, einschließlich Marktgängigkeit und Eignung für einen bestimmten Zweck.\n\n7. Soweit gesetzlich zulässig, haftet der Entwickler nicht für Schäden aus der Nutzung der Anwendung, einschließlich Datenverlust.\n\n8. Die Anwendung kann jederzeit geändert oder eingestellt werden. Wiederherstellungspasswort festlegen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5608d0f..b99cc47 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -135,7 +135,7 @@ llave acceso - PassKey guarda tus inicios de sesión, contraseñas y otras notas privadas en una base de datos cifrada en tu dispositivo.\n\nTu caja fuerte nunca sale del teléfono. No hay cuenta, ni sincronización, ni servidor de PassKey: la aplicación no realiza peticiones de red propias y tus entradas nunca se transmiten a ninguna parte.\n\nLa base de datos se cifra con SQLCipher y se desbloquea con la biometría o el bloqueo de pantalla de tu dispositivo. La caja fuerte se vuelve a bloquear automáticamente al salir de la aplicación, las capturas de pantalla están bloqueadas y las contraseñas copiadas se marcan como sensibles y se borran del portapapeles poco después.\n\nPuedes organizar las entradas en categorías, reordenarlas arrastrando, generar contraseñas seguras y revisar tu caja fuerte en busca de contraseñas débiles o repetidas. Las copias de seguridad se exportan como un único archivo cifrado con una contraseña que tú eliges y pueden restaurarse en cualquier dispositivo.\n\nPassKey es gratuita y de código abierto, desarrollada por Aditya Bhardwaj. El código fuente completo está disponible en GitHub. + PassKey guarda tus inicios de sesión, contraseñas y otras notas privadas en una base de datos cifrada en tu dispositivo.\n\nTu caja fuerte nunca sale del teléfono. No hay cuenta, ni sincronización, ni servidor de PassKey: la aplicación no realiza peticiones de red propias y tus entradas nunca se transmiten a ninguna parte.\n\nLa base de datos se cifra con SQLCipher y se desbloquea con la biometría o el bloqueo de pantalla de tu dispositivo. La caja fuerte se vuelve a bloquear automáticamente al salir de la aplicación, las capturas de pantalla están bloqueadas y las contraseñas copiadas se marcan como sensibles y se borran del portapapeles poco después.\n\nPuedes organizar las entradas en categorías, reordenarlas arrastrando, generar contraseñas seguras y revisar tu caja fuerte en busca de contraseñas débiles o repetidas. Las copias de seguridad se exportan como un único archivo cifrado con una contraseña que tú eliges y pueden restaurarse en cualquier dispositivo. PassKey puede rellenar tus inicios de sesión en otras aplicaciones y sitios web, guardar tus códigos de doble factor y conservar un historial de cada contraseña que cambies.\n\nPassKey es gratuita y de código abierto, desarrollada por Aditya Bhardwaj. El código fuente completo está disponible en GitHub. Tu caja fuerte permanece en tu dispositivo.\n\nLo que nunca recopilamos: PassKey no recopila, transmite ni almacena las contraseñas, nombres de usuario, notas ni ninguna otra entrada que guardes. Solo existen en una base de datos cifrada en tu teléfono. No podemos leerlas ni recuperarlas por ti.\n\nLo que sí envía la aplicación: PassKey incluye Google Firebase Crashlytics, Analytics y Performance Monitoring, y las actualizaciones integradas de Google Play. Estos servicios envían diagnósticos de fallos, eventos de uso, trazas de rendimiento e identificadores de dispositivo e instalación, incluido un identificador de publicidad, a Google. Nunca tienen acceso al contenido de tu caja fuerte. Por estos servicios la aplicación requiere el permiso de internet, y su tratamiento de datos se rige por la política de privacidad de Google.\n\nCopias de seguridad: al exportar una copia, el archivo se cifra con una contraseña que tú eliges y se guarda donde le indiques al sistema. Ese archivo queda bajo tu responsabilidad; cualquiera que obtenga el archivo y su contraseña podrá leerlo. No conservamos ninguna copia.\n\nCopia del dispositivo: PassKey está excluida de la copia de seguridad en la nube de Android y de la transferencia entre dispositivos, así que tu caja fuerte no se sube a tu cuenta de Google.\n\nCambios: esta política puede actualizarse; los cambios importantes se indicarán en las notas de versión de la aplicación.\n\nDudas: escribe a yrkkh.cclub@gmail.com. Al usar PassKey aceptas lo siguiente.\n\n1. PassKey es software gratuito y de código abierto desarrollado por Aditya Bhardwaj.\n\n2. Tu caja fuerte se almacena únicamente en tu dispositivo. No hay copia en servidor y, por tanto, no existe forma de recuperar tus datos si pierdes el dispositivo, desinstalas la aplicación u olvidas una contraseña de copia de seguridad.\n\n3. Mantener copias de seguridad es responsabilidad tuya. Expórtalas con regularidad y guarda la contraseña de la copia en un lugar seguro y separado.\n\n4. La aplicación depende de la seguridad biométrica o del bloqueo de pantalla de tu dispositivo. Si ese bloqueo es débil, se elimina o queda comprometido, la protección que ofrece PassKey se reduce en consecuencia.\n\n5. PassKey incluye servicios de Google Firebase y Google Play según se describe en la Política de Privacidad.\n\n6. La aplicación se proporciona \"tal cual\", sin garantía de ningún tipo, expresa o implícita, incluidas las de comerciabilidad e idoneidad para un fin determinado.\n\n7. En la medida permitida por la ley, el desarrollador no se hace responsable de los daños derivados del uso de la aplicación, incluida la pérdida de datos.\n\n8. La aplicación puede modificarse o descontinuarse en cualquier momento. Establece una contraseña de recuperación diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c2b0cc5..58c21a1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -135,7 +135,7 @@ identifiant connexion - PassKey enregistre vos identifiants, mots de passe et autres notes privées dans une base de données chiffrée sur votre appareil.\n\nVotre coffre ne quitte jamais votre téléphone. Il n\'y a ni compte, ni synchronisation, ni serveur PassKey : l\'application n\'effectue aucune requête réseau de son propre fait et vos entrées ne sont transmises nulle part.\n\nLa base de données est chiffrée avec SQLCipher et déverrouillée par la biométrie ou le verrouillage d\'écran de votre appareil. Le coffre se reverrouille automatiquement lorsque vous quittez l\'application, les captures d\'écran sont bloquées, et les mots de passe copiés sont marqués comme sensibles puis effacés du presse-papiers peu après.\n\nVous pouvez classer vos entrées par catégories, les réorganiser par glissement, générer des mots de passe robustes et vérifier votre coffre à la recherche de mots de passe faibles ou réutilisés. Les sauvegardes sont exportées sous forme d\'un fichier unique chiffré avec un mot de passe de votre choix, restaurable sur n\'importe quel appareil.\n\nPassKey est gratuite et open source, développée par Aditya Bhardwaj. Le code source complet est disponible sur GitHub. + PassKey enregistre vos identifiants, mots de passe et autres notes privées dans une base de données chiffrée sur votre appareil.\n\nVotre coffre ne quitte jamais votre téléphone. Il n\'y a ni compte, ni synchronisation, ni serveur PassKey : l\'application n\'effectue aucune requête réseau de son propre fait et vos entrées ne sont transmises nulle part.\n\nLa base de données est chiffrée avec SQLCipher et déverrouillée par la biométrie ou le verrouillage d\'écran de votre appareil. Le coffre se reverrouille automatiquement lorsque vous quittez l\'application, les captures d\'écran sont bloquées, et les mots de passe copiés sont marqués comme sensibles puis effacés du presse-papiers peu après.\n\nVous pouvez classer vos entrées par catégories, les réorganiser par glissement, générer des mots de passe robustes et vérifier votre coffre à la recherche de mots de passe faibles ou réutilisés. Les sauvegardes sont exportées sous forme d\'un fichier unique chiffré avec un mot de passe de votre choix, restaurable sur n\'importe quel appareil. PassKey peut remplir tes identifiants dans d\'autres applications et sites web, conserver tes codes à deux facteurs et garder un historique de chaque mot de passe que tu modifies.\n\nPassKey est gratuite et open source, développée par Aditya Bhardwaj. Le code source complet est disponible sur GitHub. Votre coffre reste sur votre appareil.\n\nCe que nous ne collectons jamais : PassKey ne collecte, ne transmet et ne stocke aucun des mots de passe, identifiants, notes ou autres entrées que vous enregistrez. Ils n\'existent que dans une base de données chiffrée sur votre téléphone. Nous ne pouvons ni les lire, ni les récupérer pour vous.\n\nCe que l\'application envoie : PassKey intègre Google Firebase Crashlytics, Analytics et Performance Monitoring, ainsi que les mises à jour intégrées de Google Play. Ces services envoient à Google des diagnostics de plantage, des événements d\'utilisation, des traces de performance et des identifiants d\'appareil et d\'installation, dont un identifiant publicitaire. Ils n\'ont jamais accès au contenu de votre coffre. En raison de ces services, l\'application requiert l\'autorisation d\'accès à Internet, et leur traitement des données relève de la politique de confidentialité de Google.\n\nSauvegardes : lorsque vous exportez une sauvegarde, le fichier est chiffré avec un mot de passe de votre choix et enregistré à l\'emplacement que vous indiquez au système. Ce fichier relève ensuite de votre responsabilité ; quiconque obtient à la fois le fichier et son mot de passe peut le lire. Nous n\'en conservons aucune copie.\n\nSauvegarde de l\'appareil : PassKey est exclue de la sauvegarde cloud Android et du transfert entre appareils, votre coffre n\'est donc pas envoyé vers votre compte Google.\n\nModifications : cette politique peut évoluer ; les changements importants seront signalés dans les notes de version.\n\nQuestions : contactez yrkkh.cclub@gmail.com. En utilisant PassKey, vous acceptez ce qui suit.\n\n1. PassKey est un logiciel gratuit et open source développé par Aditya Bhardwaj.\n\n2. Votre coffre est stocké uniquement sur votre appareil. Il n\'existe aucune copie côté serveur, et donc aucun moyen de récupérer vos données si vous perdez votre appareil, désinstallez l\'application ou oubliez un mot de passe de sauvegarde.\n\n3. La conservation des sauvegardes vous incombe. Exportez-les régulièrement et conservez le mot de passe de sauvegarde en lieu sûr et séparé.\n\n4. L\'application repose sur la sécurité biométrique ou le verrouillage d\'écran de votre appareil. Si ce verrouillage est faible, supprimé ou compromis, la protection offerte par PassKey est réduite d\'autant.\n\n5. PassKey intègre les services Google Firebase et Google Play décrits dans la politique de confidentialité.\n\n6. L\'application est fournie \"en l\'état\", sans garantie d\'aucune sorte, expresse ou implicite, y compris de qualité marchande et d\'adéquation à un usage particulier.\n\n7. Dans la mesure permise par la loi, le développeur n\'est pas responsable des dommages résultant de l\'utilisation de l\'application, y compris la perte de données.\n\n8. L\'application peut être modifiée ou abandonnée à tout moment. Définir un mot de passe de secours diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index 4090b7a..217022f 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -133,7 +133,7 @@ ચાવી લૉગિન - PassKey તમારા લૉગિન, પાસવર્ડ અને અન્ય ખાનગી નોંધો તમારા ઉપકરણ પરના એન્ક્રિપ્ટેડ ડેટાબેઝમાં સાચવે છે.\n\nતમારી વૉલ્ટ ક્યારેય તમારા ફોનની બહાર જતી નથી. કોઈ ખાતું નથી, સિંક નથી, PassKey નું કોઈ સર્વર પણ નથી: ઍપ પોતે કોઈ નેટવર્ક વિનંતી કરતી નથી અને તમારી નોંધો ક્યાંય મોકલાતી નથી.\n\nડેટાબેઝ SQLCipher થી એન્ક્રિપ્ટ થયેલો છે અને તમારા ઉપકરણના બાયોમેટ્રિક કે સ્ક્રીન લૉકથી ખૂલે છે. ઍપ છોડતાં જ વૉલ્ટ આપોઆપ ફરી લૉક થાય છે, સ્ક્રીનશૉટ રોકાયેલા છે, અને કૉપિ કરેલા પાસવર્ડ સંવેદનશીલ તરીકે ચિહ્નિત થઈને થોડી વારમાં ક્લિપબોર્ડમાંથી દૂર થાય છે.\n\nતમે નોંધોને શ્રેણીઓમાં ગોઠવી શકો, ખેંચીને ક્રમ બદલી શકો, મજબૂત પાસવર્ડ બનાવી શકો અને નબળા કે પુનરાવર્તિત પાસવર્ડ તપાસી શકો. બૅકઅપ એક જ ફાઇલ તરીકે નિકાસ થાય છે, તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટેડ, અને કોઈ પણ ઉપકરણ પર પુનઃસ્થાપિત થઈ શકે છે.\n\nPassKey મફત અને ઓપન સોર્સ છે, Aditya Bhardwaj દ્વારા વિકસાવાયેલ. સંપૂર્ણ સ્રોત કોડ GitHub પર ઉપલબ્ધ છે. + PassKey તમારા લૉગિન, પાસવર્ડ અને અન્ય ખાનગી નોંધો તમારા ઉપકરણ પરના એન્ક્રિપ્ટેડ ડેટાબેઝમાં સાચવે છે.\n\nતમારી વૉલ્ટ ક્યારેય તમારા ફોનની બહાર જતી નથી. કોઈ ખાતું નથી, સિંક નથી, PassKey નું કોઈ સર્વર પણ નથી: ઍપ પોતે કોઈ નેટવર્ક વિનંતી કરતી નથી અને તમારી નોંધો ક્યાંય મોકલાતી નથી.\n\nડેટાબેઝ SQLCipher થી એન્ક્રિપ્ટ થયેલો છે અને તમારા ઉપકરણના બાયોમેટ્રિક કે સ્ક્રીન લૉકથી ખૂલે છે. ઍપ છોડતાં જ વૉલ્ટ આપોઆપ ફરી લૉક થાય છે, સ્ક્રીનશૉટ રોકાયેલા છે, અને કૉપિ કરેલા પાસવર્ડ સંવેદનશીલ તરીકે ચિહ્નિત થઈને થોડી વારમાં ક્લિપબોર્ડમાંથી દૂર થાય છે.\n\nતમે નોંધોને શ્રેણીઓમાં ગોઠવી શકો, ખેંચીને ક્રમ બદલી શકો, મજબૂત પાસવર્ડ બનાવી શકો અને નબળા કે પુનરાવર્તિત પાસવર્ડ તપાસી શકો. બૅકઅપ એક જ ફાઇલ તરીકે નિકાસ થાય છે, તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટેડ, અને કોઈ પણ ઉપકરણ પર પુનઃસ્થાપિત થઈ શકે છે. PassKey અન્ય એપ્લિકેશન અને વેબસાઇટ પર તમારા લૉગિન ભરી શકે છે, તમારા બે-પરિબળ કોડ રાખી શકે છે અને તમે બદલેલા દરેક પાસવર્ડનો ઇતિહાસ સાચવી શકે છે.\n\nPassKey મફત અને ઓપન સોર્સ છે, Aditya Bhardwaj દ્વારા વિકસાવાયેલ. સંપૂર્ણ સ્રોત કોડ GitHub પર ઉપલબ્ધ છે. તમારી વૉલ્ટ તમારા ઉપકરણ પર જ રહે છે.\n\nઅમે ક્યારેય શું એકત્ર કરતા નથી: PassKey તમે સાચવેલા પાસવર્ડ, વપરાશકર્તાનામ, નોંધો કે અન્ય કોઈ પણ નોંધ એકત્ર, પ્રસારિત કે સંગ્રહિત કરતું નથી. તે માત્ર તમારા ફોનના એન્ક્રિપ્ટેડ ડેટાબેઝમાં રહે છે. અમે તે વાંચી શકતા નથી અને તમારા માટે પાછી મેળવી પણ શકતા નથી.\n\nઍપ શું મોકલે છે: PassKey માં Google Firebase Crashlytics, Analytics અને Performance Monitoring તથા Google Play ઇન-ઍપ અપડેટ સામેલ છે. આ ક્રેશ નિદાન, વપરાશ ઘટનાઓ, કામગીરી માહિતી અને જાહેરાત ID સહિત ઉપકરણ તથા ઇન્સ્ટોલેશન ઓળખકર્તાઓ Google ને મોકલે છે. તેમને તમારી વૉલ્ટની સામગ્રી સુધી ક્યારેય પ્રવેશ મળતો નથી. આ સેવાઓને કારણે ઍપને ઇન્ટરનેટ પરવાનગી જોઈએ છે, અને તેમનું ડેટા સંચાલન Google ની ગોપનીયતા નીતિ હેઠળ આવે છે.\n\nબૅકઅપ: નિકાસ વખતે ફાઇલ તમે પસંદ કરેલા પાસવર્ડથી એન્ક્રિપ્ટ થાય છે અને તમે સિસ્ટમને જ્યાં કહો ત્યાં સચવાય છે. ત્યાર બાદ તે ફાઇલની જવાબદારી તમારી છે; જેની પાસે ફાઇલ અને પાસવર્ડ બંને હોય તે તેને વાંચી શકે છે. અમે કોઈ નકલ રાખતા નથી.\n\nઉપકરણ બૅકઅપ: PassKey ને Android ક્લાઉડ બૅકઅપ અને ઉપકરણ-થી-ઉપકરણ સ્થાનાંતરણમાંથી બાકાત રાખ્યું છે, તેથી તમારી વૉલ્ટ તમારા Google ખાતામાં અપલોડ થતી નથી.\n\nફેરફારો: આ નીતિ અપડેટ થઈ શકે છે; મહત્ત્વના ફેરફારો ઍપની રિલીઝ નોંધમાં જણાવાશે.\n\nપ્રશ્નો: સંપર્ક yrkkh.cclub@gmail.com. PassKey વાપરીને તમે નીચેની બાબતો સાથે સંમત થાઓ છો.\n\n1. PassKey એ Aditya Bhardwaj દ્વારા વિકસાવાયેલ મફત, ઓપન-સોર્સ સોફ્ટવેર છે.\n\n2. તમારી વૉલ્ટ માત્ર તમારા ઉપકરણ પર સંગ્રહિત છે. સર્વર પર કોઈ નકલ નથી, તેથી ઉપકરણ ખોવાય, ઍપ દૂર કરો કે બૅકઅપ પાસવર્ડ ભૂલી જાઓ તો ડેટા પાછો મેળવવાનો કોઈ માર્ગ નથી.\n\n3. બૅકઅપ રાખવાની જવાબદારી તમારી છે. નિયમિત નિકાસ કરો અને બૅકઅપ પાસવર્ડ સુરક્ષિત, અલગ જગ્યાએ રાખો.\n\n4. ઍપ તમારા ઉપકરણની બાયોમેટ્રિક કે સ્ક્રીન-લૉક સુરક્ષા પર આધારિત છે. જો તે લૉક નબળો, દૂર કરેલો કે ભંગ થયેલો હોય, તો PassKey નું રક્ષણ તે પ્રમાણમાં ઘટે છે.\n\n5. PassKey માં ગોપનીયતા નીતિમાં વર્ણવેલ Google Firebase અને Google Play સેવાઓ સામેલ છે.\n\n6. આ એપ્લિકેશન \"જેમ છે તેમ\" પૂરી પાડવામાં આવે છે, કોઈ પણ સ્પષ્ટ કે ગર્ભિત વોરંટી વિના, જેમાં વેપારયોગ્યતા અને ચોક્કસ હેતુ માટેની યોગ્યતા સામેલ છે.\n\n7. કાયદા દ્વારા માન્ય હદ સુધી, ડેવલપર એપ્લિકેશનના ઉપયોગથી થતા કોઈ પણ નુકસાન માટે, ડેટા ગુમાવવા સહિત, જવાબદાર નથી.\n\n8. એપ્લિકેશન કોઈ પણ સમયે સુધારી કે બંધ કરી શકાય છે. પુનઃપ્રાપ્તિ પાસવર્ડ સેટ કરો diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index d0453ca..f5cc854 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -134,7 +134,7 @@ कुंजी लॉगिन - PassKey आपके लॉगिन, पासवर्ड और अन्य निजी नोट्स को आपके डिवाइस पर एक एन्क्रिप्टेड डेटाबेस में सहेजता है।\n\nआपकी वॉल्ट कभी आपके फ़ोन से बाहर नहीं जाती। न कोई खाता है, न सिंक, न PassKey का कोई सर्वर: ऐप अपनी ओर से कोई नेटवर्क अनुरोध नहीं करता और आपकी प्रविष्टियाँ कहीं नहीं भेजी जातीं।\n\nडेटाबेस SQLCipher से एन्क्रिप्ट किया गया है और आपके डिवाइस के बायोमेट्रिक या स्क्रीन लॉक से खुलता है। ऐप छोड़ते ही वॉल्ट अपने आप फिर लॉक हो जाती है, स्क्रीनशॉट रोक दिए गए हैं, और कॉपी किए गए पासवर्ड संवेदनशील चिह्नित होकर कुछ ही देर में क्लिपबोर्ड से हट जाते हैं।\n\nआप प्रविष्टियों को श्रेणियों में रख सकते हैं, खींचकर क्रम बदल सकते हैं, मज़बूत पासवर्ड बना सकते हैं और कमज़ोर या दोहराए गए पासवर्ड जाँच सकते हैं। बैकअप एक ही फ़ाइल के रूप में निर्यात होता है, जो आपके चुने पासवर्ड से एन्क्रिप्टेड होता है और किसी भी डिवाइस पर बहाल किया जा सकता है।\n\nPassKey निःशुल्क और ओपन सोर्स है, जिसे Aditya Bhardwaj ने बनाया है। पूरा स्रोत कोड GitHub पर उपलब्ध है। + PassKey आपके लॉगिन, पासवर्ड और अन्य निजी नोट्स को आपके डिवाइस पर एक एन्क्रिप्टेड डेटाबेस में सहेजता है।\n\nआपकी वॉल्ट कभी आपके फ़ोन से बाहर नहीं जाती। न कोई खाता है, न सिंक, न PassKey का कोई सर्वर: ऐप अपनी ओर से कोई नेटवर्क अनुरोध नहीं करता और आपकी प्रविष्टियाँ कहीं नहीं भेजी जातीं।\n\nडेटाबेस SQLCipher से एन्क्रिप्ट किया गया है और आपके डिवाइस के बायोमेट्रिक या स्क्रीन लॉक से खुलता है। ऐप छोड़ते ही वॉल्ट अपने आप फिर लॉक हो जाती है, स्क्रीनशॉट रोक दिए गए हैं, और कॉपी किए गए पासवर्ड संवेदनशील चिह्नित होकर कुछ ही देर में क्लिपबोर्ड से हट जाते हैं।\n\nआप प्रविष्टियों को श्रेणियों में रख सकते हैं, खींचकर क्रम बदल सकते हैं, मज़बूत पासवर्ड बना सकते हैं और कमज़ोर या दोहराए गए पासवर्ड जाँच सकते हैं। बैकअप एक ही फ़ाइल के रूप में निर्यात होता है, जो आपके चुने पासवर्ड से एन्क्रिप्टेड होता है और किसी भी डिवाइस पर बहाल किया जा सकता है। PassKey आपके लॉगिन दूसरे ऐप्स और वेबसाइटों में भर सकता है, आपके टू-फ़ैक्टर कोड रख सकता है, और आपके बदले हुए हर पासवर्ड का इतिहास रख सकता है।\n\nPassKey निःशुल्क और ओपन सोर्स है, जिसे Aditya Bhardwaj ने बनाया है। पूरा स्रोत कोड GitHub पर उपलब्ध है। आपकी वॉल्ट आपके डिवाइस पर ही रहती है।\n\nहम कभी क्या एकत्र नहीं करते: PassKey आपके सहेजे गए पासवर्ड, उपयोगकर्ता नाम, नोट्स या किसी भी प्रविष्टि को एकत्र, प्रेषित या संग्रहीत नहीं करता। ये केवल आपके फ़ोन के एन्क्रिप्टेड डेटाबेस में रहते हैं। हम न इन्हें पढ़ सकते हैं, न आपके लिए वापस ला सकते हैं।\n\nऐप क्या भेजता है: PassKey में Google Firebase Crashlytics, Analytics और Performance Monitoring तथा Google Play इन-ऐप अपडेट शामिल हैं। ये क्रैश डायग्नोस्टिक्स, उपयोग इवेंट, प्रदर्शन डेटा और विज्ञापन आईडी सहित डिवाइस व इंस्टॉलेशन पहचानकर्ता Google को भेजते हैं। इनकी पहुँच आपकी वॉल्ट की सामग्री तक कभी नहीं होती। इन सेवाओं के कारण ऐप को इंटरनेट अनुमति चाहिए, और इनका डेटा प्रबंधन Google की गोपनीयता नीति से संचालित होता है।\n\nबैकअप: निर्यात करते समय फ़ाइल आपके चुने पासवर्ड से एन्क्रिप्ट होती है और वहीं सहेजी जाती है जहाँ आप सिस्टम को बताते हैं। उसके बाद उस फ़ाइल की ज़िम्मेदारी आपकी है; जिसके पास फ़ाइल और पासवर्ड दोनों हों वह उसे पढ़ सकता है। हम कोई प्रति नहीं रखते।\n\nडिवाइस बैकअप: PassKey को Android क्लाउड बैकअप और डिवाइस-टू-डिवाइस ट्रांसफ़र से बाहर रखा गया है, इसलिए आपकी वॉल्ट आपके Google खाते में अपलोड नहीं होती।\n\nबदलाव: यह नीति अद्यतन हो सकती है; महत्वपूर्ण बदलाव ऐप के रिलीज़ नोट्स में बताए जाएँगे।\n\nप्रश्न: संपर्क करें yrkkh.cclub@gmail.com। PassKey का उपयोग करके आप निम्नलिखित से सहमत होते हैं।\n\n1. PassKey एक निःशुल्क, ओपन-सोर्स सॉफ़्टवेयर है जिसे Aditya Bhardwaj ने विकसित किया है।\n\n2. आपकी वॉल्ट केवल आपके डिवाइस पर संग्रहीत होती है। सर्वर पर कोई प्रति नहीं है, इसलिए डिवाइस खोने, ऐप हटाने या बैकअप पासवर्ड भूलने पर डेटा वापस पाने का कोई उपाय नहीं है।\n\n3. बैकअप रखना आपकी ज़िम्मेदारी है। इन्हें नियमित रूप से निर्यात करें और बैकअप पासवर्ड को सुरक्षित, अलग जगह रखें।\n\n4. ऐप आपके डिवाइस की बायोमेट्रिक या स्क्रीन-लॉक सुरक्षा पर निर्भर करता है। यदि वह लॉक कमज़ोर, हटाया हुआ या असुरक्षित है, तो PassKey द्वारा दी गई सुरक्षा उसी अनुपात में घट जाती है।\n\n5. PassKey में गोपनीयता नीति में वर्णित Google Firebase और Google Play सेवाएँ शामिल हैं।\n\n6. यह एप्लिकेशन \"जैसा है\" प्रदान किया जाता है, बिना किसी व्यक्त या निहित वारंटी के, जिसमें व्यापारिकता और किसी विशेष उद्देश्य के लिए उपयुक्तता शामिल है।\n\n7. कानून द्वारा अनुमत सीमा तक, डेवलपर एप्लिकेशन के उपयोग से उत्पन्न किसी भी क्षति, जिसमें डेटा हानि शामिल है, के लिए उत्तरदायी नहीं है।\n\n8. एप्लिकेशन को किसी भी समय संशोधित या बंद किया जा सकता है। पुनर्प्राप्ति पासवर्ड सेट करें diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bd12671..60521de 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -135,7 +135,7 @@ chiave accesso - PassKey salva credenziali, password e altre note private in un database cifrato sul tuo dispositivo.\n\nLa tua cassaforte non lascia mai il telefono. Non c\'è alcun account, nessuna sincronizzazione e nessun server PassKey: l\'app non effettua richieste di rete proprie e le tue voci non vengono trasmesse da nessuna parte.\n\nIl database è cifrato con SQLCipher e si sblocca con la biometria o il blocco schermo del dispositivo. La cassaforte si riblocca automaticamente quando esci dall\'app, gli screenshot sono bloccati e le password copiate vengono contrassegnate come sensibili ed eliminate dagli appunti poco dopo.\n\nPuoi organizzare le voci in categorie, riordinarle trascinando, generare password robuste e controllare la cassaforte alla ricerca di password deboli o riutilizzate. I backup vengono esportati come un unico file cifrato con una password scelta da te e si possono ripristinare su qualsiasi dispositivo.\n\nPassKey è gratuita e open source, sviluppata da Aditya Bhardwaj. Il codice sorgente completo è disponibile su GitHub. + PassKey salva credenziali, password e altre note private in un database cifrato sul tuo dispositivo.\n\nLa tua cassaforte non lascia mai il telefono. Non c\'è alcun account, nessuna sincronizzazione e nessun server PassKey: l\'app non effettua richieste di rete proprie e le tue voci non vengono trasmesse da nessuna parte.\n\nIl database è cifrato con SQLCipher e si sblocca con la biometria o il blocco schermo del dispositivo. La cassaforte si riblocca automaticamente quando esci dall\'app, gli screenshot sono bloccati e le password copiate vengono contrassegnate come sensibili ed eliminate dagli appunti poco dopo.\n\nPuoi organizzare le voci in categorie, riordinarle trascinando, generare password robuste e controllare la cassaforte alla ricerca di password deboli o riutilizzate. I backup vengono esportati come un unico file cifrato con una password scelta da te e si possono ripristinare su qualsiasi dispositivo. PassKey può compilare le tue credenziali in altre app e siti web, custodire i tuoi codici a due fattori e conservare una cronologia di ogni password che cambi.\n\nPassKey è gratuita e open source, sviluppata da Aditya Bhardwaj. Il codice sorgente completo è disponibile su GitHub. La tua cassaforte resta sul tuo dispositivo.\n\nCosa non raccogliamo mai: PassKey non raccoglie, trasmette né memorizza le password, i nomi utente, le note o qualsiasi altra voce che salvi. Esistono soltanto in un database cifrato sul tuo telefono. Non possiamo leggerle né recuperarle per te.\n\nCosa invia l\'app: PassKey include Google Firebase Crashlytics, Analytics e Performance Monitoring e gli aggiornamenti in-app di Google Play. Questi servizi inviano a Google diagnostiche di arresto anomalo, eventi di utilizzo, tracce prestazionali e identificatori di dispositivo e installazione, compreso un ID pubblicitario. Non hanno mai accesso al contenuto della cassaforte. A causa di questi servizi l\'app richiede l\'autorizzazione a Internet, e il loro trattamento dei dati è regolato dall\'informativa privacy di Google.\n\nBackup: quando esporti un backup, il file viene cifrato con una password scelta da te e salvato dove indichi al sistema. Quel file è poi sotto la tua responsabilità; chiunque ottenga sia il file sia la password può leggerlo. Non ne conserviamo copia.\n\nBackup del dispositivo: PassKey è esclusa dal backup cloud di Android e dal trasferimento tra dispositivi, quindi la cassaforte non viene caricata sul tuo account Google.\n\nModifiche: questa informativa può essere aggiornata; le modifiche rilevanti saranno indicate nelle note di versione.\n\nDomande: scrivi a yrkkh.cclub@gmail.com. Usando PassKey accetti quanto segue.\n\n1. PassKey è software gratuito e open source sviluppato da Aditya Bhardwaj.\n\n2. La tua cassaforte è archiviata solo sul tuo dispositivo. Non esiste copia lato server e quindi non c\'è modo di recuperare i dati se perdi il dispositivo, disinstalli l\'app o dimentichi una password di backup.\n\n3. Conservare i backup è una tua responsabilità. Esportali regolarmente e custodisci la password del backup in un luogo sicuro e separato.\n\n4. L\'app si appoggia alla sicurezza biometrica o al blocco schermo del dispositivo. Se tale blocco è debole, rimosso o compromesso, la protezione offerta da PassKey si riduce di conseguenza.\n\n5. PassKey include i servizi Google Firebase e Google Play descritti nell\'Informativa sulla privacy.\n\n6. L\'applicazione è fornita \"così com\'è\", senza garanzie di alcun tipo, esplicite o implicite, comprese quelle di commerciabilità e idoneità a uno scopo particolare.\n\n7. Nei limiti consentiti dalla legge, lo sviluppatore non è responsabile per danni derivanti dall\'uso dell\'applicazione, inclusa la perdita di dati.\n\n8. L\'applicazione può essere modificata o dismessa in qualsiasi momento. Imposta una password di recupero diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d0616e9..74c2e12 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -132,7 +132,7 @@ 鍵 ログイン - PassKey は、ログイン情報・パスワード・その他の非公開メモを、お使いの端末上の暗号化データベースに保存します。\n\n保管庫が端末外に出ることはありません。アカウントも同期も PassKey のサーバーも存在せず、アプリ自身はネットワーク通信を行わないため、登録内容がどこかへ送信されることはありません。\n\nデータベースは SQLCipher で暗号化され、端末の生体認証または画面ロックで解錠します。アプリを離れると保管庫は自動的に再ロックされ、スクリーンショットは禁止され、コピーしたパスワードは機密として扱われ、まもなくクリップボードから消去されます。\n\n項目はカテゴリ分けやドラッグによる並べ替えができ、強力なパスワードの生成や、脆弱・使い回しパスワードの点検も行えます。バックアップは、ご自身で決めたパスワードで暗号化した単一のファイルとして書き出され、どの端末でも復元できます。\n\nPassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアで、ソースコードはすべて GitHub で公開されています。 + PassKey は、ログイン情報・パスワード・その他の非公開メモを、お使いの端末上の暗号化データベースに保存します。\n\n保管庫が端末外に出ることはありません。アカウントも同期も PassKey のサーバーも存在せず、アプリ自身はネットワーク通信を行わないため、登録内容がどこかへ送信されることはありません。\n\nデータベースは SQLCipher で暗号化され、端末の生体認証または画面ロックで解錠します。アプリを離れると保管庫は自動的に再ロックされ、スクリーンショットは禁止され、コピーしたパスワードは機密として扱われ、まもなくクリップボードから消去されます。\n\n項目はカテゴリ分けやドラッグによる並べ替えができ、強力なパスワードの生成や、脆弱・使い回しパスワードの点検も行えます。バックアップは、ご自身で決めたパスワードで暗号化した単一のファイルとして書き出され、どの端末でも復元できます。 PassKey は他のアプリやウェブサイトにログイン情報を入力し、二要素認証コードを保管し、変更したパスワードの履歴を残すことができます。\n\nPassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアで、ソースコードはすべて GitHub で公開されています。 保管庫は端末内に留まります。\n\n収集しないもの: PassKey は、保存されたパスワード・ユーザー名・メモなどの項目を一切収集・送信・保管しません。それらは端末上の暗号化データベースにのみ存在します。当方が内容を読むことも、復元することもできません。\n\nアプリが送信するもの: PassKey には Google Firebase Crashlytics、Analytics、Performance Monitoring、および Google Play のアプリ内更新が含まれます。これらはクラッシュ診断、利用イベント、パフォーマンス情報、広告 ID を含む端末およびインストール識別子を Google に送信します。保管庫の中身にアクセスすることはありません。これらのサービスのため、アプリはインターネット権限を必要とし、そのデータの取り扱いは Google のプライバシーポリシーに従います。\n\nバックアップ: 書き出したファイルはご自身で決めたパスワードで暗号化され、システムで指定した場所に保存されます。以後そのファイルの管理はご自身の責任となり、ファイルとパスワードの双方を入手した者は内容を読むことができます。当方は控えを保持しません。\n\n端末バックアップ: PassKey は Android のクラウドバックアップおよび端末間転送の対象外です。保管庫が Google アカウントへアップロードされることはありません。\n\n変更: 本ポリシーは更新される場合があります。重要な変更はリリースノートに記載します。\n\nお問い合わせ: yrkkh.cclub@gmail.com までご連絡ください。 PassKey を利用することで、以下に同意したものとみなされます。\n\n1. PassKey は Aditya Bhardwaj が開発した無料のオープンソースソフトウェアです。\n\n2. 保管庫は端末上にのみ保存されます。サーバー側の控えは存在しないため、端末の紛失、アプリの削除、バックアップパスワードの失念があった場合にデータを復元する手段はありません。\n\n3. バックアップの保持は利用者の責任です。定期的に書き出し、バックアップパスワードは安全な別の場所に保管してください。\n\n4. 本アプリは端末の生体認証または画面ロックに依存します。そのロックが脆弱、解除済み、または侵害されている場合、PassKey による保護もその分低下します。\n\n5. PassKey にはプライバシーポリシーに記載の Google Firebase および Google Play のサービスが含まれます。\n\n6. 本アプリケーションは「現状有姿」で提供され、商品性および特定目的への適合性を含め、明示黙示を問わずいかなる保証も行いません。\n\n7. 法令が許す範囲において、開発者はデータの喪失を含む本アプリケーションの利用に起因する損害について責任を負いません。\n\n8. 本アプリケーションは予告なく変更または提供終了となる場合があります。 復旧用パスワードを設定 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 00f53bb..0cdf365 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -133,7 +133,7 @@ 키 로그인 - PassKey는 로그인 정보, 비밀번호, 그 밖의 비공개 메모를 기기 안의 암호화된 데이터베이스에 저장합니다.\n\n보관함은 기기를 벗어나지 않습니다. 계정도, 동기화도, PassKey 서버도 없습니다. 앱 자체는 네트워크 요청을 하지 않으며 저장한 항목이 어디로도 전송되지 않습니다.\n\n데이터베이스는 SQLCipher로 암호화되며 기기의 생체 인증이나 화면 잠금으로 해제합니다. 앱을 벗어나면 보관함이 자동으로 다시 잠기고, 스크린샷은 차단되며, 복사한 비밀번호는 민감 정보로 표시된 뒤 잠시 후 클립보드에서 지워집니다.\n\n항목을 카테고리로 정리하고 끌어서 순서를 바꿀 수 있으며, 강력한 비밀번호를 생성하고 취약하거나 재사용된 비밀번호를 점검할 수 있습니다. 백업은 직접 정한 비밀번호로 암호화된 단일 파일로 내보내지며 어떤 기기에서도 복원할 수 있습니다.\n\nPassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어이며 전체 소스 코드는 GitHub에서 공개되어 있습니다. + PassKey는 로그인 정보, 비밀번호, 그 밖의 비공개 메모를 기기 안의 암호화된 데이터베이스에 저장합니다.\n\n보관함은 기기를 벗어나지 않습니다. 계정도, 동기화도, PassKey 서버도 없습니다. 앱 자체는 네트워크 요청을 하지 않으며 저장한 항목이 어디로도 전송되지 않습니다.\n\n데이터베이스는 SQLCipher로 암호화되며 기기의 생체 인증이나 화면 잠금으로 해제합니다. 앱을 벗어나면 보관함이 자동으로 다시 잠기고, 스크린샷은 차단되며, 복사한 비밀번호는 민감 정보로 표시된 뒤 잠시 후 클립보드에서 지워집니다.\n\n항목을 카테고리로 정리하고 끌어서 순서를 바꿀 수 있으며, 강력한 비밀번호를 생성하고 취약하거나 재사용된 비밀번호를 점검할 수 있습니다. 백업은 직접 정한 비밀번호로 암호화된 단일 파일로 내보내지며 어떤 기기에서도 복원할 수 있습니다. PassKey는 다른 앱과 웹사이트에 로그인 정보를 입력하고, 2단계 인증 코드를 보관하며, 변경한 비밀번호의 기록을 남길 수 있습니다.\n\nPassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어이며 전체 소스 코드는 GitHub에서 공개되어 있습니다. 보관함은 기기에 남아 있습니다.\n\n수집하지 않는 것: PassKey는 저장한 비밀번호, 사용자 이름, 메모 등 어떤 항목도 수집하거나 전송하거나 보관하지 않습니다. 해당 정보는 기기의 암호화된 데이터베이스에만 존재합니다. 저희는 이를 읽을 수도, 대신 복구해 드릴 수도 없습니다.\n\n앱이 전송하는 것: PassKey에는 Google Firebase Crashlytics, Analytics, Performance Monitoring과 Google Play 인앱 업데이트가 포함됩니다. 이들은 비정상 종료 진단, 사용 이벤트, 성능 기록, 광고 ID를 포함한 기기 및 설치 식별자를 Google로 전송합니다. 보관함 내용에는 접근하지 않습니다. 이러한 서비스 때문에 앱은 인터넷 권한을 필요로 하며, 해당 데이터 처리는 Google의 개인정보처리방침을 따릅니다.\n\n백업: 백업을 내보내면 직접 정한 비밀번호로 파일이 암호화되어 지정한 위치에 저장됩니다. 이후 그 파일은 사용자의 책임이며, 파일과 비밀번호를 모두 가진 사람은 내용을 읽을 수 있습니다. 저희는 사본을 보관하지 않습니다.\n\n기기 백업: PassKey는 Android 클라우드 백업과 기기 간 전송에서 제외되어 보관함이 Google 계정에 업로드되지 않습니다.\n\n변경: 본 방침은 변경될 수 있으며, 중요한 변경은 앱 출시 노트에 기재됩니다.\n\n문의: yrkkh.cclub@gmail.com 로 연락해 주세요. PassKey를 사용함으로써 다음에 동의하게 됩니다.\n\n1. PassKey는 Aditya Bhardwaj가 개발한 무료 오픈소스 소프트웨어입니다.\n\n2. 보관함은 기기에만 저장됩니다. 서버 사본이 없으므로 기기를 분실하거나 앱을 삭제하거나 백업 비밀번호를 잊은 경우 데이터를 복구할 방법이 없습니다.\n\n3. 백업 보관은 사용자의 책임입니다. 정기적으로 내보내고 백업 비밀번호는 안전한 별도 장소에 보관하세요.\n\n4. 앱은 기기의 생체 인증 또는 화면 잠금 보안에 의존합니다. 해당 잠금이 취약하거나 해제되거나 침해되면 PassKey가 제공하는 보호도 그만큼 약해집니다.\n\n5. PassKey에는 개인정보처리방침에 기술된 Google Firebase 및 Google Play 서비스가 포함됩니다.\n\n6. 본 애플리케이션은 상품성 및 특정 목적 적합성을 포함하여 명시적이든 묵시적이든 어떠한 보증도 없이 \"있는 그대로\" 제공됩니다.\n\n7. 법이 허용하는 범위에서 개발자는 데이터 손실을 포함해 애플리케이션 사용으로 발생한 손해에 대해 책임지지 않습니다.\n\n8. 애플리케이션은 언제든 변경되거나 중단될 수 있습니다. 복구 비밀번호 설정 diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 0508606..66e5e69 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -134,7 +134,7 @@ किल्ली लॉगिन - PassKey तुमचे लॉगिन, पासवर्ड आणि इतर खाजगी नोंदी तुमच्या उपकरणावरील एन्क्रिप्टेड डेटाबेसमध्ये साठवते.\n\nतुमची व्हॉल्ट कधीही फोनबाहेर जात नाही. खाते नाही, सिंक नाही, PassKey चा सर्व्हरही नाही: ॲप स्वतःहून कोणतीही नेटवर्क विनंती करत नाही आणि तुमच्या नोंदी कुठेही पाठवल्या जात नाहीत.\n\nडेटाबेस SQLCipher ने एन्क्रिप्ट केलेला असून तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन लॉकने उघडतो. ॲप सोडताच व्हॉल्ट आपोआप पुन्हा लॉक होते, स्क्रीनशॉट रोखलेले आहेत, आणि कॉपी केलेले पासवर्ड संवेदनशील म्हणून चिन्हांकित होऊन थोड्याच वेळात क्लिपबोर्डमधून हटवले जातात.\n\nनोंदी श्रेणींमध्ये मांडता येतात, ओढून क्रम बदलता येतो, मजबूत पासवर्ड तयार करता येतात आणि कमकुवत किंवा पुन्हा वापरलेले पासवर्ड तपासता येतात. बॅकअप एकाच फाइलमध्ये निर्यात होतो, तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट केलेला, आणि कोणत्याही उपकरणावर पुनर्संचयित करता येतो.\n\nPassKey विनामूल्य आणि मुक्तस्रोत आहे, Aditya Bhardwaj यांनी विकसित केलेले. संपूर्ण स्रोत कोड GitHub वर उपलब्ध आहे. + PassKey तुमचे लॉगिन, पासवर्ड आणि इतर खाजगी नोंदी तुमच्या उपकरणावरील एन्क्रिप्टेड डेटाबेसमध्ये साठवते.\n\nतुमची व्हॉल्ट कधीही फोनबाहेर जात नाही. खाते नाही, सिंक नाही, PassKey चा सर्व्हरही नाही: ॲप स्वतःहून कोणतीही नेटवर्क विनंती करत नाही आणि तुमच्या नोंदी कुठेही पाठवल्या जात नाहीत.\n\nडेटाबेस SQLCipher ने एन्क्रिप्ट केलेला असून तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन लॉकने उघडतो. ॲप सोडताच व्हॉल्ट आपोआप पुन्हा लॉक होते, स्क्रीनशॉट रोखलेले आहेत, आणि कॉपी केलेले पासवर्ड संवेदनशील म्हणून चिन्हांकित होऊन थोड्याच वेळात क्लिपबोर्डमधून हटवले जातात.\n\nनोंदी श्रेणींमध्ये मांडता येतात, ओढून क्रम बदलता येतो, मजबूत पासवर्ड तयार करता येतात आणि कमकुवत किंवा पुन्हा वापरलेले पासवर्ड तपासता येतात. बॅकअप एकाच फाइलमध्ये निर्यात होतो, तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट केलेला, आणि कोणत्याही उपकरणावर पुनर्संचयित करता येतो. PassKey तुमचे लॉगिन इतर अ‍ॅप्स आणि वेबसाइटवर भरू शकते, तुमचे दोन-घटक कोड ठेवू शकते आणि तुम्ही बदललेल्या प्रत्येक पासवर्डचा इतिहास ठेवू शकते.\n\nPassKey विनामूल्य आणि मुक्तस्रोत आहे, Aditya Bhardwaj यांनी विकसित केलेले. संपूर्ण स्रोत कोड GitHub वर उपलब्ध आहे. तुमची व्हॉल्ट तुमच्या उपकरणावरच राहते.\n\nआम्ही कधीही काय गोळा करत नाही: PassKey तुम्ही जतन केलेले पासवर्ड, वापरकर्तानावे, नोंदी किंवा इतर कोणतीही माहिती गोळा, प्रसारित किंवा साठवत नाही. ती फक्त तुमच्या फोनवरील एन्क्रिप्टेड डेटाबेसमध्ये असते. आम्ही ती वाचू शकत नाही आणि तुमच्यासाठी परत मिळवूही शकत नाही.\n\nॲप काय पाठवते: PassKey मध्ये Google Firebase Crashlytics, Analytics आणि Performance Monitoring, तसेच Google Play इन-ॲप अपडेट समाविष्ट आहेत. हे क्रॅश निदान, वापर घटना, कार्यक्षमता माहिती आणि जाहिरात आयडीसह उपकरण व स्थापना ओळखचिन्हे Google ला पाठवतात. त्यांना तुमच्या व्हॉल्टच्या मजकुरापर्यंत कधीही प्रवेश नसतो. या सेवांमुळे ॲपला इंटरनेट परवानगी लागते आणि त्यांची माहिती हाताळणी Google च्या गोपनीयता धोरणानुसार होते.\n\nबॅकअप: निर्यात करताना फाइल तुम्ही निवडलेल्या पासवर्डने एन्क्रिप्ट होते आणि तुम्ही सिस्टमला सांगाल तिथे साठवली जाते. त्यानंतर ती फाइल तुमची जबाबदारी आहे; ज्याच्याकडे फाइल आणि पासवर्ड दोन्ही असेल तो ती वाचू शकतो. आम्ही प्रत ठेवत नाही.\n\nउपकरण बॅकअप: PassKey ला Android क्लाउड बॅकअप आणि उपकरण-ते-उपकरण हस्तांतरणातून वगळले आहे, त्यामुळे तुमची व्हॉल्ट तुमच्या Google खात्यावर अपलोड होत नाही.\n\nबदल: हे धोरण अद्ययावत होऊ शकते; महत्त्वाचे बदल ॲपच्या रिलीज नोट्समध्ये नमूद केले जातील.\n\nप्रश्न: संपर्क yrkkh.cclub@gmail.com. PassKey वापरून तुम्ही पुढील बाबींना सहमती देता.\n\n1. PassKey हे Aditya Bhardwaj यांनी विकसित केलेले विनामूल्य, मुक्तस्रोत सॉफ्टवेअर आहे.\n\n2. तुमची व्हॉल्ट फक्त तुमच्या उपकरणावर साठवली जाते. सर्व्हरवर प्रत नाही, त्यामुळे उपकरण हरवल्यास, ॲप काढून टाकल्यास किंवा बॅकअप पासवर्ड विसरल्यास डेटा परत मिळवण्याचा मार्ग नाही.\n\n3. बॅकअप ठेवणे ही तुमची जबाबदारी आहे. ते नियमितपणे निर्यात करा आणि बॅकअप पासवर्ड सुरक्षित, वेगळ्या ठिकाणी ठेवा.\n\n4. ॲप तुमच्या उपकरणाच्या बायोमेट्रिक किंवा स्क्रीन-लॉक सुरक्षेवर अवलंबून आहे. ते लॉक कमकुवत, काढलेले किंवा तडजोड झालेले असल्यास PassKey देत असलेले संरक्षण त्या प्रमाणात कमी होते.\n\n5. PassKey मध्ये गोपनीयता धोरणात वर्णन केलेल्या Google Firebase आणि Google Play सेवा समाविष्ट आहेत.\n\n6. हे ॲप्लिकेशन \"जसे आहे तसे\" पुरवले जाते, कोणत्याही स्पष्ट किंवा गर्भित हमीशिवाय, ज्यात व्यापारयोग्यता आणि विशिष्ट हेतूसाठी उपयुक्तता समाविष्ट आहे.\n\n7. कायद्याने परवानगी दिलेल्या मर्यादेत, ॲप्लिकेशनच्या वापरामुळे होणाऱ्या कोणत्याही नुकसानीस, डेटा हानीसह, विकसक जबाबदार नाही.\n\n8. ॲप्लिकेशन कधीही सुधारले किंवा बंद केले जाऊ शकते. पुनर्प्राप्ती पासवर्ड सेट करा diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1b14944..9b0cb3a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -135,7 +135,7 @@ chave acesso - O PassKey guarda seus logins, senhas e outras notas privadas em um banco de dados criptografado no seu dispositivo.\n\nSeu cofre nunca sai do celular. Não há conta, sincronização nem servidor do PassKey: o aplicativo não faz requisições de rede próprias e suas entradas nunca são transmitidas para lugar algum.\n\nO banco de dados é criptografado com SQLCipher e desbloqueado com a biometria ou o bloqueio de tela do aparelho. O cofre volta a bloquear automaticamente quando você sai do app, capturas de tela são bloqueadas e senhas copiadas são marcadas como sensíveis e apagadas da área de transferência logo depois.\n\nVocê pode organizar entradas em categorias, reordená-las arrastando, gerar senhas fortes e verificar o cofre em busca de senhas fracas ou repetidas. Os backups são exportados como um único arquivo criptografado com uma senha escolhida por você e podem ser restaurados em qualquer dispositivo.\n\nO PassKey é gratuito e de código aberto, desenvolvido por Aditya Bhardwaj. O código-fonte completo está disponível no GitHub. + O PassKey guarda seus logins, senhas e outras notas privadas em um banco de dados criptografado no seu dispositivo.\n\nSeu cofre nunca sai do celular. Não há conta, sincronização nem servidor do PassKey: o aplicativo não faz requisições de rede próprias e suas entradas nunca são transmitidas para lugar algum.\n\nO banco de dados é criptografado com SQLCipher e desbloqueado com a biometria ou o bloqueio de tela do aparelho. O cofre volta a bloquear automaticamente quando você sai do app, capturas de tela são bloqueadas e senhas copiadas são marcadas como sensíveis e apagadas da área de transferência logo depois.\n\nVocê pode organizar entradas em categorias, reordená-las arrastando, gerar senhas fortes e verificar o cofre em busca de senhas fracas ou repetidas. Os backups são exportados como um único arquivo criptografado com uma senha escolhida por você e podem ser restaurados em qualquer dispositivo. O PassKey pode preencher seus logins em outros aplicativos e sites, guardar seus códigos de dois fatores e manter um histórico de cada senha que você alterar.\n\nO PassKey é gratuito e de código aberto, desenvolvido por Aditya Bhardwaj. O código-fonte completo está disponível no GitHub. Seu cofre permanece no seu dispositivo.\n\nO que nunca coletamos: o PassKey não coleta, transmite nem armazena as senhas, nomes de usuário, notas ou quaisquer outras entradas que você salva. Elas existem apenas em um banco de dados criptografado no seu celular. Não podemos lê-las nem recuperá-las para você.\n\nO que o aplicativo envia: o PassKey inclui Google Firebase Crashlytics, Analytics e Performance Monitoring, além das atualizações no app do Google Play. Esses serviços enviam ao Google diagnósticos de falhas, eventos de uso, rastros de desempenho e identificadores de dispositivo e instalação, incluindo um identificador de publicidade. Eles nunca têm acesso ao conteúdo do seu cofre. Por causa desses serviços o app exige a permissão de internet, e o tratamento desses dados segue a política de privacidade do Google.\n\nBackups: ao exportar um backup, o arquivo é criptografado com uma senha escolhida por você e gravado onde você indicar ao sistema. Esse arquivo passa a ser responsabilidade sua; quem obtiver o arquivo e a senha poderá lê-lo. Não guardamos cópia.\n\nBackup do dispositivo: o PassKey está excluído do backup na nuvem do Android e da transferência entre dispositivos, então seu cofre não é enviado para sua conta Google.\n\nAlterações: esta política pode ser atualizada; mudanças relevantes serão indicadas nas notas de versão.\n\nDúvidas: escreva para yrkkh.cclub@gmail.com. Ao usar o PassKey você concorda com o seguinte.\n\n1. O PassKey é software gratuito e de código aberto desenvolvido por Aditya Bhardwaj.\n\n2. Seu cofre é armazenado apenas no seu dispositivo. Não existe cópia em servidor e, portanto, não há como recuperar seus dados se você perder o aparelho, desinstalar o app ou esquecer uma senha de backup.\n\n3. Manter backups é responsabilidade sua. Exporte-os regularmente e guarde a senha do backup em local seguro e separado.\n\n4. O app depende da segurança biométrica ou do bloqueio de tela do seu aparelho. Se esse bloqueio for fraco, removido ou comprometido, a proteção oferecida pelo PassKey diminui na mesma medida.\n\n5. O PassKey inclui serviços do Google Firebase e do Google Play conforme descrito na Política de Privacidade.\n\n6. O aplicativo é fornecido \"no estado em que se encontra\", sem garantia de qualquer tipo, expressa ou implícita, incluindo comercialização e adequação a uma finalidade específica.\n\n7. Na medida permitida por lei, o desenvolvedor não se responsabiliza por danos decorrentes do uso do aplicativo, incluindo perda de dados.\n\n8. O aplicativo pode ser modificado ou descontinuado a qualquer momento. Defina uma senha de recuperação diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 35776ea..72051a8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -135,7 +135,7 @@ ключ вход - PassKey хранит ваши учётные данные, пароли и другие личные заметки в зашифрованной базе данных на вашем устройстве.\n\nВаше хранилище никогда не покидает телефон. Нет ни учётной записи, ни синхронизации, ни сервера PassKey: приложение не выполняет собственных сетевых запросов, и ваши записи никуда не передаются.\n\nБаза данных зашифрована с помощью SQLCipher и разблокируется биометрией или блокировкой экрана устройства. Хранилище автоматически блокируется, когда вы выходите из приложения, снимки экрана запрещены, а скопированные пароли помечаются как конфиденциальные и вскоре удаляются из буфера обмена.\n\nЗаписи можно распределять по категориям, менять их порядок перетаскиванием, генерировать надёжные пароли и проверять хранилище на слабые или повторяющиеся пароли. Резервные копии экспортируются одним файлом, зашифрованным выбранным вами паролем, и восстанавливаются на любом устройстве.\n\nPassKey — бесплатное приложение с открытым исходным кодом, разработанное Aditya Bhardwaj. Полный исходный код доступен на GitHub. + PassKey хранит ваши учётные данные, пароли и другие личные заметки в зашифрованной базе данных на вашем устройстве.\n\nВаше хранилище никогда не покидает телефон. Нет ни учётной записи, ни синхронизации, ни сервера PassKey: приложение не выполняет собственных сетевых запросов, и ваши записи никуда не передаются.\n\nБаза данных зашифрована с помощью SQLCipher и разблокируется биометрией или блокировкой экрана устройства. Хранилище автоматически блокируется, когда вы выходите из приложения, снимки экрана запрещены, а скопированные пароли помечаются как конфиденциальные и вскоре удаляются из буфера обмена.\n\nЗаписи можно распределять по категориям, менять их порядок перетаскиванием, генерировать надёжные пароли и проверять хранилище на слабые или повторяющиеся пароли. Резервные копии экспортируются одним файлом, зашифрованным выбранным вами паролем, и восстанавливаются на любом устройстве. PassKey может подставлять ваши данные для входа в другие приложения и на сайты, хранить коды двухфакторной аутентификации и вести историю каждого изменённого пароля.\n\nPassKey — бесплатное приложение с открытым исходным кодом, разработанное Aditya Bhardwaj. Полный исходный код доступен на GitHub. Ваше хранилище остаётся на вашем устройстве.\n\nЧто мы никогда не собираем: PassKey не собирает, не передаёт и не хранит пароли, имена пользователей, заметки и любые другие сохранённые вами записи. Они существуют только в зашифрованной базе данных на вашем телефоне. Мы не можем ни прочитать их, ни восстановить для вас.\n\nЧто отправляет приложение: PassKey включает Google Firebase Crashlytics, Analytics и Performance Monitoring, а также встроенные обновления Google Play. Эти сервисы отправляют в Google диагностику сбоев, события использования, данные о производительности, а также идентификаторы устройства и установки, включая рекламный идентификатор. Доступа к содержимому хранилища у них нет никогда. Из-за этих сервисов приложению требуется разрешение на доступ в интернет, а обработка этих данных регулируется политикой конфиденциальности Google.\n\nРезервные копии: при экспорте файл шифруется выбранным вами паролем и сохраняется туда, куда вы укажете системе. Далее этот файл — ваша ответственность; любой, у кого окажутся и файл, и пароль, сможет его прочитать. Мы копий не храним.\n\nРезервное копирование устройства: PassKey исключено из облачного резервного копирования Android и переноса между устройствами, поэтому хранилище не выгружается в ваш аккаунт Google.\n\nИзменения: политика может обновляться; существенные изменения отмечаются в примечаниях к выпуску.\n\nВопросы: пишите на yrkkh.cclub@gmail.com. Используя PassKey, вы соглашаетесь со следующим.\n\n1. PassKey — бесплатное программное обеспечение с открытым исходным кодом, разработанное Aditya Bhardwaj.\n\n2. Ваше хранилище находится только на вашем устройстве. Серверной копии нет, а значит нет и способа восстановить данные, если вы потеряете устройство, удалите приложение или забудете пароль резервной копии.\n\n3. Резервные копии — ваша ответственность. Делайте их регулярно и храните пароль от копии в надёжном и отдельном месте.\n\n4. Приложение опирается на биометрию или блокировку экрана вашего устройства. Если эта защита слабая, отключена или скомпрометирована, защита со стороны PassKey снижается соответственно.\n\n5. PassKey включает сервисы Google Firebase и Google Play, как описано в политике конфиденциальности.\n\n6. Приложение предоставляется «как есть», без каких-либо гарантий, явных или подразумеваемых, включая пригодность для продажи и соответствие конкретной цели.\n\n7. В пределах, допускаемых законом, разработчик не несёт ответственности за ущерб, возникший в результате использования приложения, включая потерю данных.\n\n8. Приложение может быть изменено или прекращено в любое время. Задайте пароль восстановления diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 4d99146..7d417fe 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -133,7 +133,7 @@ சாவி உள்நுழைவு - PassKey உங்கள் உள்நுழைவுத் தகவல்கள், கடவுச்சொற்கள் மற்றும் பிற தனிப்பட்ட குறிப்புகளை உங்கள் சாதனத்தில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் சேமிக்கிறது.\n\nஉங்கள் பெட்டகம் ஒருபோதும் தொலைபேசியை விட்டு வெளியே செல்வதில்லை. கணக்கு இல்லை, ஒத்திசைவு இல்லை, PassKey சேவையகமும் இல்லை: ஆப்ஸ் தானாக எந்த நெட்வொர்க் கோரிக்கையும் செய்வதில்லை, உங்கள் பதிவுகள் எங்கும் அனுப்பப்படுவதில்லை.\n\nதரவுத்தளம் SQLCipher மூலம் மறையாக்கப்பட்டு, உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டால் திறக்கப்படுகிறது. ஆப்ஸை விட்டு வெளியேறியதும் பெட்டகம் தானாகவே மீண்டும் பூட்டப்படும், திரைப்பிடிப்புகள் தடுக்கப்படுகின்றன, நகலெடுத்த கடவுச்சொற்கள் முக்கியமானவை எனக் குறிக்கப்பட்டு சிறிது நேரத்தில் இடைநிலைப் பலகையிலிருந்து அழிக்கப்படுகின்றன.\n\nபதிவுகளை வகைகளாக ஒழுங்குபடுத்தலாம், இழுத்து வரிசை மாற்றலாம், வலுவான கடவுச்சொற்களை உருவாக்கலாம், பலவீனமான அல்லது மீண்டும் பயன்படுத்திய கடவுச்சொற்களைச் சரிபார்க்கலாம். காப்புப்பிரதிகள் நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்ட ஒற்றைக் கோப்பாக ஏற்றுமதி செய்யப்பட்டு எந்தச் சாதனத்திலும் மீட்டெடுக்கப்படலாம்.\n\nPassKey இலவசமானது, திறந்த மூலமானது, Aditya Bhardwaj உருவாக்கியது. முழு மூலக் குறியீடும் GitHub இல் கிடைக்கிறது. + PassKey உங்கள் உள்நுழைவுத் தகவல்கள், கடவுச்சொற்கள் மற்றும் பிற தனிப்பட்ட குறிப்புகளை உங்கள் சாதனத்தில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் சேமிக்கிறது.\n\nஉங்கள் பெட்டகம் ஒருபோதும் தொலைபேசியை விட்டு வெளியே செல்வதில்லை. கணக்கு இல்லை, ஒத்திசைவு இல்லை, PassKey சேவையகமும் இல்லை: ஆப்ஸ் தானாக எந்த நெட்வொர்க் கோரிக்கையும் செய்வதில்லை, உங்கள் பதிவுகள் எங்கும் அனுப்பப்படுவதில்லை.\n\nதரவுத்தளம் SQLCipher மூலம் மறையாக்கப்பட்டு, உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டால் திறக்கப்படுகிறது. ஆப்ஸை விட்டு வெளியேறியதும் பெட்டகம் தானாகவே மீண்டும் பூட்டப்படும், திரைப்பிடிப்புகள் தடுக்கப்படுகின்றன, நகலெடுத்த கடவுச்சொற்கள் முக்கியமானவை எனக் குறிக்கப்பட்டு சிறிது நேரத்தில் இடைநிலைப் பலகையிலிருந்து அழிக்கப்படுகின்றன.\n\nபதிவுகளை வகைகளாக ஒழுங்குபடுத்தலாம், இழுத்து வரிசை மாற்றலாம், வலுவான கடவுச்சொற்களை உருவாக்கலாம், பலவீனமான அல்லது மீண்டும் பயன்படுத்திய கடவுச்சொற்களைச் சரிபார்க்கலாம். காப்புப்பிரதிகள் நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்ட ஒற்றைக் கோப்பாக ஏற்றுமதி செய்யப்பட்டு எந்தச் சாதனத்திலும் மீட்டெடுக்கப்படலாம். PassKey உங்கள் உள்நுழைவுத் தகவலை மற்ற செயலிகளிலும் இணையதளங்களிலும் நிரப்பலாம், உங்கள் இரு-காரணி குறியீடுகளை வைத்திருக்கலாம், மேலும் நீங்கள் மாற்றும் ஒவ்வொரு கடவுச்சொல்லின் வரலாற்றையும் வைத்திருக்கலாம்.\n\nPassKey இலவசமானது, திறந்த மூலமானது, Aditya Bhardwaj உருவாக்கியது. முழு மூலக் குறியீடும் GitHub இல் கிடைக்கிறது. உங்கள் பெட்டகம் உங்கள் சாதனத்திலேயே இருக்கும்.\n\nநாங்கள் ஒருபோதும் சேகரிக்காதவை: நீங்கள் சேமிக்கும் கடவுச்சொற்கள், பயனர்பெயர்கள், குறிப்புகள் அல்லது பிற எந்தப் பதிவையும் PassKey சேகரிக்கவோ, அனுப்பவோ, சேமிக்கவோ இல்லை. அவை உங்கள் தொலைபேசியில் உள்ள மறையாக்கப்பட்ட தரவுத்தளத்தில் மட்டுமே இருக்கும். அவற்றை எங்களால் படிக்கவும் முடியாது, உங்களுக்காக மீட்டெடுக்கவும் முடியாது.\n\nஆப்ஸ் அனுப்புபவை: PassKey இல் Google Firebase Crashlytics, Analytics, Performance Monitoring மற்றும் Google Play ஆப்ஸ்-உள் புதுப்பிப்புகள் உள்ளன. இவை செயலிழப்பு நோயறிதல், பயன்பாட்டு நிகழ்வுகள், செயல்திறன் தரவு, விளம்பர ID உள்ளிட்ட சாதன மற்றும் நிறுவல் அடையாளங்காட்டிகளை Google க்கு அனுப்புகின்றன. உங்கள் பெட்டக உள்ளடக்கத்திற்கு இவற்றுக்கு ஒருபோதும் அணுகல் இல்லை. இச்சேவைகளால் ஆப்ஸுக்கு இணைய அனுமதி தேவை, அவற்றின் தரவு கையாளுதல் Google இன் தனியுரிமைக் கொள்கையின் கீழ் வருகிறது.\n\nகாப்புப்பிரதிகள்: ஏற்றுமதி செய்யும்போது கோப்பு நீங்கள் தேர்ந்தெடுத்த கடவுச்சொல்லால் மறையாக்கப்பட்டு, நீங்கள் கணினியிடம் குறிப்பிட்ட இடத்தில் சேமிக்கப்படும். அதன் பிறகு அக்கோப்பு உங்கள் பொறுப்பு; கோப்பையும் கடவுச்சொல்லையும் பெறுபவர் அதைப் படிக்க முடியும். நாங்கள் நகல் வைத்திருப்பதில்லை.\n\nசாதனக் காப்புப்பிரதி: PassKey Android கிளவுட் காப்புப்பிரதி மற்றும் சாதனங்களுக்கிடையேயான இடமாற்றத்திலிருந்து விலக்கப்பட்டுள்ளது, எனவே உங்கள் பெட்டகம் உங்கள் Google கணக்கிற்குப் பதிவேற்றப்படுவதில்லை.\n\nமாற்றங்கள்: இக்கொள்கை புதுப்பிக்கப்படலாம்; முக்கிய மாற்றங்கள் ஆப்ஸின் வெளியீட்டுக் குறிப்புகளில் தெரிவிக்கப்படும்.\n\nகேள்விகள்: தொடர்பு yrkkh.cclub@gmail.com. PassKey ஐப் பயன்படுத்துவதன் மூலம் பின்வருவனவற்றை ஏற்கிறீர்கள்.\n\n1. PassKey என்பது Aditya Bhardwaj உருவாக்கிய இலவச, திறந்த மூல மென்பொருள்.\n\n2. உங்கள் பெட்டகம் உங்கள் சாதனத்தில் மட்டுமே சேமிக்கப்படுகிறது. சேவையகத்தில் நகல் இல்லை, எனவே சாதனத்தை இழந்தால், ஆப்ஸை நீக்கினால் அல்லது காப்புப்பிரதி கடவுச்சொல்லை மறந்தால் தரவை மீட்க வழி இல்லை.\n\n3. காப்புப்பிரதிகளை வைத்திருப்பது உங்கள் பொறுப்பு. அவற்றை வழக்கமாக ஏற்றுமதி செய்து, காப்புப்பிரதி கடவுச்சொல்லைப் பாதுகாப்பான, தனித்த இடத்தில் வைக்கவும்.\n\n4. ஆப்ஸ் உங்கள் சாதனத்தின் உயிரியல் அல்லது திரைப் பூட்டுப் பாதுகாப்பை நம்பியுள்ளது. அப்பூட்டு பலவீனமாக, அகற்றப்பட்டதாக அல்லது சமரசம் செய்யப்பட்டதாக இருந்தால், PassKey அளிக்கும் பாதுகாப்பும் அதற்கேற்ப குறையும்.\n\n5. PassKey இல் தனியுரிமைக் கொள்கையில் விவரிக்கப்பட்ட Google Firebase மற்றும் Google Play சேவைகள் உள்ளன.\n\n6. இப்பயன்பாடு \"உள்ளபடி\" வழங்கப்படுகிறது, வணிகத்தகுதி மற்றும் குறிப்பிட்ட நோக்கத்திற்கான உகந்ததன்மை உட்பட, வெளிப்படையான அல்லது மறைமுகமான எந்த உத்தரவாதமும் இன்றி.\n\n7. சட்டம் அனுமதிக்கும் அளவில், பயன்பாட்டின் பயன்பாட்டால் ஏற்படும் எந்த சேதத்திற்கும், தரவு இழப்பு உட்பட, உருவாக்குநர் பொறுப்பல்ல.\n\n8. பயன்பாடு எந்த நேரத்திலும் மாற்றப்படலாம் அல்லது நிறுத்தப்படலாம். மீட்பு கடவுச்சொல்லை அமைக்கவும் diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 3cc7a88..ac15010 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -133,7 +133,7 @@ కీ లాగిన్ - PassKey మీ లాగిన్‌లు, పాస్‌వర్డ్‌లు మరియు ఇతర వ్యక్తిగత గమనికలను మీ పరికరంలోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో నిల్వ చేస్తుంది.\n\nమీ వాల్ట్ ఎప్పుడూ మీ ఫోన్‌ను వదిలి వెళ్లదు. ఖాతా లేదు, సింక్ లేదు, PassKey సర్వర్ కూడా లేదు: యాప్ తనంతట తానుగా ఎలాంటి నెట్‌వర్క్ అభ్యర్థనలూ చేయదు, మీ ఎంట్రీలు ఎక్కడికీ పంపబడవు.\n\nడేటాబేస్ SQLCipher తో ఎన్‌క్రిప్ట్ చేయబడి, మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్ లాక్‌తో తెరుచుకుంటుంది. యాప్ నుండి బయటకు వెళ్లగానే వాల్ట్ స్వయంచాలకంగా మళ్లీ లాక్ అవుతుంది, స్క్రీన్‌షాట్‌లు నిరోధించబడ్డాయి, కాపీ చేసిన పాస్‌వర్డ్‌లు సున్నితమైనవిగా గుర్తించబడి కొద్దిసేపటిలో క్లిప్‌బోర్డ్ నుండి తొలగించబడతాయి.\n\nఎంట్రీలను వర్గాలుగా అమర్చవచ్చు, లాగి క్రమం మార్చవచ్చు, బలమైన పాస్‌వర్డ్‌లు సృష్టించవచ్చు, బలహీనమైన లేదా పునరావృత పాస్‌వర్డ్‌లను తనిఖీ చేయవచ్చు. బ్యాకప్‌లు మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ చేసిన ఒకే ఫైల్‌గా ఎగుమతి అవుతాయి, ఏ పరికరంలోనైనా పునరుద్ధరించవచ్చు.\n\nPassKey ఉచితం, ఓపెన్ సోర్స్, Aditya Bhardwaj అభివృద్ధి చేశారు. పూర్తి సోర్స్ కోడ్ GitHub లో అందుబాటులో ఉంది. + PassKey మీ లాగిన్‌లు, పాస్‌వర్డ్‌లు మరియు ఇతర వ్యక్తిగత గమనికలను మీ పరికరంలోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో నిల్వ చేస్తుంది.\n\nమీ వాల్ట్ ఎప్పుడూ మీ ఫోన్‌ను వదిలి వెళ్లదు. ఖాతా లేదు, సింక్ లేదు, PassKey సర్వర్ కూడా లేదు: యాప్ తనంతట తానుగా ఎలాంటి నెట్‌వర్క్ అభ్యర్థనలూ చేయదు, మీ ఎంట్రీలు ఎక్కడికీ పంపబడవు.\n\nడేటాబేస్ SQLCipher తో ఎన్‌క్రిప్ట్ చేయబడి, మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్ లాక్‌తో తెరుచుకుంటుంది. యాప్ నుండి బయటకు వెళ్లగానే వాల్ట్ స్వయంచాలకంగా మళ్లీ లాక్ అవుతుంది, స్క్రీన్‌షాట్‌లు నిరోధించబడ్డాయి, కాపీ చేసిన పాస్‌వర్డ్‌లు సున్నితమైనవిగా గుర్తించబడి కొద్దిసేపటిలో క్లిప్‌బోర్డ్ నుండి తొలగించబడతాయి.\n\nఎంట్రీలను వర్గాలుగా అమర్చవచ్చు, లాగి క్రమం మార్చవచ్చు, బలమైన పాస్‌వర్డ్‌లు సృష్టించవచ్చు, బలహీనమైన లేదా పునరావృత పాస్‌వర్డ్‌లను తనిఖీ చేయవచ్చు. బ్యాకప్‌లు మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ చేసిన ఒకే ఫైల్‌గా ఎగుమతి అవుతాయి, ఏ పరికరంలోనైనా పునరుద్ధరించవచ్చు. PassKey మీ లాగిన్‌లను ఇతర యాప్‌లు మరియు వెబ్‌సైట్‌లలో నింపగలదు, మీ రెండు-కారక కోడ్‌లను ఉంచగలదు, మరియు మీరు మార్చిన ప్రతి పాస్‌వర్డ్ చరిత్రను ఉంచగలదు.\n\nPassKey ఉచితం, ఓపెన్ సోర్స్, Aditya Bhardwaj అభివృద్ధి చేశారు. పూర్తి సోర్స్ కోడ్ GitHub లో అందుబాటులో ఉంది. మీ వాల్ట్ మీ పరికరంలోనే ఉంటుంది.\n\nమేము ఎప్పుడూ సేకరించనివి: మీరు సేవ్ చేసిన పాస్‌వర్డ్‌లు, వినియోగదారు పేర్లు, గమనికలు లేదా ఇతర ఏ ఎంట్రీలనూ PassKey సేకరించదు, పంపదు, నిల్వ చేయదు. అవి మీ ఫోన్‌లోని ఎన్‌క్రిప్ట్ చేసిన డేటాబేస్‌లో మాత్రమే ఉంటాయి. మేము వాటిని చదవలేము, మీ కోసం తిరిగి పొందలేము.\n\nయాప్ పంపేవి: PassKey లో Google Firebase Crashlytics, Analytics, Performance Monitoring మరియు Google Play ఇన్-యాప్ అప్‌డేట్‌లు ఉన్నాయి. ఇవి క్రాష్ డయాగ్నస్టిక్స్, వినియోగ ఈవెంట్‌లు, పనితీరు డేటా, ప్రకటన IDతో సహా పరికర మరియు ఇన్‌స్టాలేషన్ గుర్తింపులను Google కు పంపుతాయి. మీ వాల్ట్ కంటెంట్‌కు వీటికి ఎప్పుడూ ప్రాప్యత ఉండదు. ఈ సేవల కారణంగా యాప్‌కు ఇంటర్నెట్ అనుమతి అవసరం, వాటి డేటా నిర్వహణ Google గోప్యతా విధానం ప్రకారం జరుగుతుంది.\n\nబ్యాకప్‌లు: ఎగుమతి చేసినప్పుడు ఫైల్ మీరు ఎంచుకున్న పాస్‌వర్డ్‌తో ఎన్‌క్రిప్ట్ అవుతుంది, మీరు సిస్టమ్‌కు చెప్పిన చోట నిల్వ అవుతుంది. ఆ తర్వాత ఆ ఫైల్ మీ బాధ్యత; ఫైల్ మరియు పాస్‌వర్డ్ రెండూ పొందిన ఎవరైనా దాన్ని చదవగలరు. మేము ఎలాంటి కాపీ ఉంచుకోము.\n\nపరికర బ్యాకప్: PassKey Android క్లౌడ్ బ్యాకప్ మరియు పరికరాల మధ్య బదిలీ నుండి మినహాయించబడింది, కాబట్టి మీ వాల్ట్ మీ Google ఖాతాకు అప్‌లోడ్ కాదు.\n\nమార్పులు: ఈ విధానం నవీకరించబడవచ్చు; ముఖ్యమైన మార్పులు యాప్ విడుదల గమనికలలో తెలియజేయబడతాయి.\n\nప్రశ్నలు: సంప్రదించండి yrkkh.cclub@gmail.com. PassKey ను ఉపయోగించడం ద్వారా మీరు కింది వాటికి అంగీకరిస్తున్నారు.\n\n1. PassKey అనేది Aditya Bhardwaj అభివృద్ధి చేసిన ఉచిత, ఓపెన్-సోర్స్ సాఫ్ట్‌వేర్.\n\n2. మీ వాల్ట్ మీ పరికరంలో మాత్రమే నిల్వ చేయబడుతుంది. సర్వర్‌లో కాపీ లేదు, కాబట్టి పరికరం పోగొట్టుకున్నా, యాప్ అన్‌ఇన్‌స్టాల్ చేసినా, బ్యాకప్ పాస్‌వర్డ్ మరచిపోయినా డేటాను తిరిగి పొందే మార్గం లేదు.\n\n3. బ్యాకప్‌లు ఉంచుకోవడం మీ బాధ్యత. వాటిని క్రమం తప్పకుండా ఎగుమతి చేయండి, బ్యాకప్ పాస్‌వర్డ్‌ను సురక్షితమైన, వేరే చోట ఉంచండి.\n\n4. యాప్ మీ పరికరం బయోమెట్రిక్ లేదా స్క్రీన్-లాక్ భద్రతపై ఆధారపడి ఉంటుంది. ఆ లాక్ బలహీనంగా, తొలగించబడి లేదా రాజీపడి ఉంటే, PassKey అందించే రక్షణ కూడా అదే మేరకు తగ్గుతుంది.\n\n5. PassKey లో గోప్యతా విధానంలో వివరించిన Google Firebase మరియు Google Play సేవలు ఉన్నాయి.\n\n6. ఈ అప్లికేషన్ \"ఉన్నది ఉన్నట్లుగా\" అందించబడుతుంది, వర్తకయోగ్యత మరియు నిర్దిష్ట ప్రయోజనానికి అనుకూలత సహా, ఎలాంటి స్పష్టమైన లేదా అంతర్గత హామీ లేకుండా.\n\n7. చట్టం అనుమతించిన మేరకు, అప్లికేషన్ వినియోగం వల్ల కలిగే ఏ నష్టానికీ, డేటా నష్టంతో సహా, డెవలపర్ బాధ్యత వహించరు.\n\n8. అప్లికేషన్ ఎప్పుడైనా మార్చబడవచ్చు లేదా నిలిపివేయబడవచ్చు. రికవరీ పాస్‌వర్డ్ సెట్ చేయండి diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6b10cc8..dbf0238 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -133,7 +133,7 @@ 秘密 登录 - PassKey 将你的登录信息、密码和其他私密备注保存在设备上的加密数据库中。\n\n你的密码库永远不会离开手机。没有账号、没有同步、也没有 PassKey 服务器:应用本身不发起任何网络请求,你的条目不会被传输到任何地方。\n\n数据库使用 SQLCipher 加密,通过设备的生物识别或屏幕锁解锁。离开应用后密码库会自动重新锁定,截图被禁用,复制的密码会被标记为敏感内容并在稍后从剪贴板清除。\n\n你可以按类别整理条目、拖动排序、生成高强度密码,并检查密码库中是否存在弱密码或重复使用的密码。备份会导出为单个文件,使用你自己设定的密码加密,可在任意设备上恢复。\n\nPassKey 由 Aditya Bhardwaj 开发,免费且开源,完整源代码可在 GitHub 上获取。 + PassKey 将你的登录信息、密码和其他私密备注保存在设备上的加密数据库中。\n\n你的密码库永远不会离开手机。没有账号、没有同步、也没有 PassKey 服务器:应用本身不发起任何网络请求,你的条目不会被传输到任何地方。\n\n数据库使用 SQLCipher 加密,通过设备的生物识别或屏幕锁解锁。离开应用后密码库会自动重新锁定,截图被禁用,复制的密码会被标记为敏感内容并在稍后从剪贴板清除。\n\n你可以按类别整理条目、拖动排序、生成高强度密码,并检查密码库中是否存在弱密码或重复使用的密码。备份会导出为单个文件,使用你自己设定的密码加密,可在任意设备上恢复。 PassKey 可以将你的登录信息填入其他应用和网站,保存你的双重验证码,并为你更改过的每个密码保留历史记录。\n\nPassKey 由 Aditya Bhardwaj 开发,免费且开源,完整源代码可在 GitHub 上获取。 你的密码库保留在你的设备上。\n\n我们从不收集的内容:PassKey 不会收集、传输或存储你保存的密码、用户名、备注或任何其他条目。它们只存在于你手机上的加密数据库中。我们既无法读取,也无法替你恢复。\n\n应用确实会发送的内容:PassKey 集成了 Google Firebase Crashlytics、Analytics 和 Performance Monitoring,以及 Google Play 应用内更新。这些服务会向 Google 发送崩溃诊断、使用事件、性能数据,以及包含广告 ID 在内的设备和安装标识符。它们始终无法访问你的密码库内容。由于这些服务,应用需要网络权限,其数据处理受 Google 隐私政策约束。\n\n备份:导出备份时,文件会使用你设定的密码加密,并保存到你指定的位置。此后该文件由你自行保管;同时获得文件和密码的人即可读取其内容。我们不保留任何副本。\n\n设备备份:PassKey 已排除在 Android 云备份和设备间转移之外,因此你的密码库不会上传到你的 Google 账号。\n\n变更:本政策可能更新,重大变更会在应用的版本说明中注明。\n\n如有疑问,请联系 yrkkh.cclub@gmail.com。 使用 PassKey 即表示你同意以下条款。\n\n1. PassKey 是由 Aditya Bhardwaj 开发的免费开源软件。\n\n2. 你的密码库仅存储在你的设备上。不存在服务器端副本,因此如果你丢失设备、卸载应用或忘记备份密码,将无法恢复数据。\n\n3. 保留备份是你的责任。请定期导出,并将备份密码保存在安全且独立的地方。\n\n4. 本应用依赖你设备的生物识别或屏幕锁安全性。若该锁定较弱、被移除或已被攻破,PassKey 提供的保护也会相应降低。\n\n5. PassKey 包含隐私政策中所述的 Google Firebase 和 Google Play 服务。\n\n6. 本应用按\"现状\"提供,不作任何明示或暗示的保证,包括适销性和特定用途适用性。\n\n7. 在法律允许的范围内,开发者不对因使用本应用而产生的任何损害负责,包括数据丢失。\n\n8. 本应用可能随时被修改或停止提供。 设置恢复密码 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4fb2ee2..7849d6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,7 +30,7 @@ Your vault never leaves your phone. There is no account, no sync and no PassKey The database is encrypted with SQLCipher and unlocked with your device biometrics or screen lock. The vault re-locks automatically when you leave the app, screenshots are blocked, and copied passwords are marked sensitive and cleared from the clipboard shortly after. -You can organise entries into categories, reorder them by dragging, generate strong passwords, and check your vault for weak or reused ones. Backups are exported as a single file encrypted with a password you choose, and can be restored on any device. +You can organise entries into categories, reorder them by dragging, generate strong passwords, and check your vault for weak or reused ones. PassKey can fill your logins into other apps and websites, hold your two-factor authenticator codes, and keep a history of each password you change. Backups are exported as a single file encrypted with a password you choose, and can be restored on any device. PassKey is free and open source, developed by Aditya Bhardwaj. The full source is available on GitHub. Your vault stays on your device. From 4c7e0a877a8cca151b6f375aa9cbb07b7b374678 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 12:31:39 +0530 Subject: [PATCH 24/25] Cover the backup round trip end to end The backup file is the only copy of a vault that leaves the device, and until now only its pieces were tested: the crypto, the payload schema, and the legacy CSV parser, each on its own. A field silently dropped between them would not have failed anything. Eight tests drive the real export and import through a content URI, and assert what matters about the file rather than only that it round-trips: that the written bytes contain none of the entry text, that a wrong password and a corrupt file are reported identically - distinguishing them would tell someone holding the file when they had guessed the password right - that importing the same file twice adds nothing the second time, and that isSecret and authenticator parameters both survive. Robolectric is used only for the ContentResolver; everything under test is the app's own code. 118 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 5 +- .../bhardwaj/passkey/BackupRepositoryTest.kt | 223 ++++++++++++++++++ 2 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/bhardwaj/passkey/BackupRepositoryTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f42cc3..0998a69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -191,8 +191,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.turbine) - // Only DetailViewModelTest needs this: SavedStateHandle.toRoute builds an android.os.Bundle - // internally, which a plain JVM test cannot. Every other unit test runs without it. + // Two tests need this: DetailViewModelTest, because SavedStateHandle.toRoute builds an + // android.os.Bundle internally, and BackupRepositoryTest, for a working ContentResolver. + // Every other unit test runs on a plain JVM. testImplementation(libs.robolectric) androidTestImplementation(libs.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/app/src/test/java/com/bhardwaj/passkey/BackupRepositoryTest.kt b/app/src/test/java/com/bhardwaj/passkey/BackupRepositoryTest.kt new file mode 100644 index 0000000..39d4744 --- /dev/null +++ b/app/src/test/java/com/bhardwaj/passkey/BackupRepositoryTest.kt @@ -0,0 +1,223 @@ +package com.bhardwaj.passkey + +import android.net.Uri +import com.bhardwaj.passkey.data.backup.BackupError +import com.bhardwaj.passkey.data.backup.BackupException +import com.bhardwaj.passkey.data.backup.BackupRepository +import com.bhardwaj.passkey.data.backup.ImportMode +import com.bhardwaj.passkey.domain.model.Category +import com.bhardwaj.passkey.domain.totp.Base32 +import com.bhardwaj.passkey.domain.totp.TotpAlgorithm +import com.bhardwaj.passkey.domain.totp.TotpConfig +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream + +/** + * Exercises the real export and import paths end to end, through a content URI. + * + * Robolectric is needed only for the ContentResolver; everything under test is the app's own + * code. The backup file is the one copy of a vault that leaves the device, so a round trip that + * silently drops a field is a defect nobody notices until they restore. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +@OptIn(ExperimentalCoroutinesApi::class) +class BackupRepositoryTest { + + private val context: android.app.Application get() = RuntimeEnvironment.getApplication() + private val password get() = "correct horse battery".toCharArray() + + private fun repositoryWith(vault: FakePasskeyRepository) = BackupRepository(context, vault) + + /** Captures what export writes, and hands the same bytes back to import. */ + private class BackupFile(private val context: android.app.Application) { + val uri: Uri = Uri.parse("content://test/backup.pkbak") + private val sink = ByteArrayOutputStream() + + fun captureWrites() { + shadowOf(context.contentResolver).registerOutputStream(uri, sink) + } + + fun bytes(): ByteArray = sink.toByteArray() + + fun serveForReading(bytes: ByteArray = bytes()) { + shadowOf(context.contentResolver).registerInputStream(uri, bytes.inputStream()) + } + } + + private suspend fun seedVault(vault: FakePasskeyRepository) { + val gmail = vault.createPreview("Gmail", Category.MAILS) + vault.createDetail(gmail, "Username", "alice@example.com", sequence = 0) + vault.createDetail(gmail, "Password", "s3cret", sequence = 1, isSecret = true) + vault.createTotp( + previewId = gmail, + label = "Gmail", + config = TotpConfig( + secret = Base32.decode("GEZDGNBVGY3TQOJQ")!!, + issuer = "Google", + algorithm = TotpAlgorithm.SHA256, + digits = 8, + periodSeconds = 60 + ) + ) + } + + @Test + fun `a vault survives a round trip, secrets and authenticators included`() = runTest { + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + + assertThat(repositoryWith(source).export(file.uri, password).isSuccess).isTrue() + + val restored = FakePasskeyRepository() + file.serveForReading() + val summary = repositoryWith(restored).import(file.uri, password, ImportMode.MERGE) + + assertThat(summary.isSuccess).isTrue() + assertThat(summary.getOrThrow().previewsAdded).isEqualTo(1) + assertThat(summary.getOrThrow().detailsAdded).isEqualTo(2) + + val secret = restored.currentDetails().single { it.question == "Password" } + // Lost before schema 2, which silently reset every restored entry to keyword matching. + assertThat(secret.isSecret).isTrue() + + val authenticator = restored.currentTotps().single() + assertThat(authenticator.config.digits).isEqualTo(8) + assertThat(authenticator.config.periodSeconds).isEqualTo(60) + assertThat(authenticator.config.algorithm).isEqualTo(TotpAlgorithm.SHA256) + assertThat(authenticator.config.secret).isEqualTo(Base32.decode("GEZDGNBVGY3TQOJQ")) + } + + @Test + fun `the written file is not readable as text`() = runTest { + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + + repositoryWith(source).export(file.uri, password) + + val raw = file.bytes().toString(Charsets.ISO_8859_1) + assertThat(raw).startsWith("PKBACKUP") + assertThat(raw).doesNotContain("s3cret") + assertThat(raw).doesNotContain("alice@example.com") + assertThat(raw).doesNotContain("Gmail") + } + + @Test + fun `the wrong password is refused and nothing is written`() = runTest { + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + repositoryWith(source).export(file.uri, password) + + val restored = FakePasskeyRepository() + file.serveForReading() + val result = repositoryWith(restored) + .import(file.uri, "wrong password".toCharArray(), ImportMode.MERGE) + + assertThat((result.exceptionOrNull() as BackupException).error) + .isEqualTo(BackupError.WrongPasswordOrCorrupt) + assertThat(restored.currentPreviews()).isEmpty() + } + + @Test + fun `a corrupt file is reported the same way as a wrong password`() = runTest { + // Distinguishing them would tell an attacker holding the file when they had guessed the + // password correctly but hit a damaged byte. + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + repositoryWith(source).export(file.uri, password) + + val damaged = file.bytes().also { it[it.size - 1] = (it[it.size - 1] + 1).toByte() } + file.serveForReading(damaged) + + val result = repositoryWith(FakePasskeyRepository()) + .import(file.uri, password, ImportMode.MERGE) + assertThat((result.exceptionOrNull() as BackupException).error) + .isEqualTo(BackupError.WrongPasswordOrCorrupt) + } + + @Test + fun `importing twice does not duplicate entries`() = runTest { + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + repositoryWith(source).export(file.uri, password) + val bytes = file.bytes() + + val restored = FakePasskeyRepository() + file.serveForReading(bytes) + repositoryWith(restored).import(file.uri, password, ImportMode.MERGE) + file.serveForReading(bytes) + val second = repositoryWith(restored).import(file.uri, password, ImportMode.MERGE) + + assertThat(restored.currentPreviews()).hasSize(1) + assertThat(restored.currentDetails()).hasSize(2) + assertThat(second.getOrThrow().detailsAdded).isEqualTo(0) + } + + @Test + fun `replace-all clears what was there before restoring`() = runTest { + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + repositoryWith(source).export(file.uri, password) + + val restored = FakePasskeyRepository() + restored.createPreview("Something else", Category.OTHERS) + file.serveForReading() + repositoryWith(restored).import(file.uri, password, ImportMode.REPLACE_ALL) + + assertThat(restored.currentPreviews().map { it.heading }).containsExactly("Gmail") + } + + @Test + fun `an encrypted file offered without a password is refused, not parsed`() = runTest { + val source = FakePasskeyRepository() + seedVault(source) + val file = BackupFile(context) + file.captureWrites() + repositoryWith(source).export(file.uri, password) + file.serveForReading() + + val result = repositoryWith(FakePasskeyRepository()) + .import(file.uri, password = null, mode = ImportMode.MERGE) + + assertThat((result.exceptionOrNull() as BackupException).error) + .isEqualTo(BackupError.WrongPasswordOrCorrupt) + } + + @Test + fun `a legacy plaintext CSV still restores, and an unknown category becomes OTHERS`() = + runTest { + // The format this app itself wrote before 5.6.0. Category.valueOf used to be called + // on this untrusted field and threw, aborting a part-finished import. + // Column order and header line are the old exporter's own. + val csv = "Category,Heading,Question,Answer\nNOT_A_CATEGORY,Gmail,Password,s3cret\n" + val file = BackupFile(context) + file.serveForReading(csv.toByteArray()) + + val restored = FakePasskeyRepository() + val result = repositoryWith(restored).import(file.uri, null, ImportMode.MERGE) + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrThrow().legacyFormat).isTrue() + assertThat(restored.currentPreviews().single().category).isEqualTo(Category.OTHERS) + } +} From f04ea0a6fa8d7825f6c7e67f907f53e1f30c2f50 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 13:35:27 +0530 Subject: [PATCH 25/25] Close the last four plan items: language source of truth, and three guards **One list of languages.** The picker declared 17 entries inline inside a composable, locales_config declared 17 more, and the values-* folders were a third list. They had already drifted once - that drift *was* the Arabic bug, where the language was offered and translated but missing from locales_config, so choosing it silently did nothing. Adding the missing line fixed the symptom; an AppLanguage enum removes the shape that allowed it, and the Language data class is gone. **LocalizationCoverageTest**, which the plan asked for and nothing had delivered. MissingTranslation catches a key absent from a folder; it cannot catch a language the picker offers with no folder behind it, or a locale missing from locales_config. Writing it found one thing worth keeping: the Italian for "Privacy" is "Privacy", so legitimate coincidences are an explicit allowlist rather than a weaker assertion. **AppUpdateControllerTest**, six tests against Play's own FakeAppUpdateManager (which ships inside app-update; there is no separate testing artifact). The update gate cannot be exercised for real from a local build and is what a hotfix depends on, and the bug it replaced was a silent fall-through, so the assertions are mostly about refusal. The first version raced Play's async callback and passed alone but failed in the full suite; it now waits, and the two negative cases settle first so they cannot pass for the wrong reason. **SecureModalBottomSheet** plus a check task. Every sheet already passed SecureFlagPolicy.SecureOn individually, so this fixes no live leak - it removes the chance to forget. The script is wired to `check`, because a guard nobody runs is not a guard. Also `setRecentsScreenshotEnabled(false)` on API 33+, so the recents thumbnail is blacked out deliberately rather than as a side effect of FLAG_SECURE. 118 unit tests, 32 instrumented tests, three consecutive clean runs. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 14 ++ .../passkey/AppUpdateControllerTest.kt | 175 ++++++++++++++++++ .../passkey/LocalizationCoverageTest.kt | 110 +++++++++++ .../passkey/domain/model/AppLanguage.kt | 45 +++++ .../bhardwaj/passkey/domain/model/Language.kt | 7 - .../passkey/presentation/MainActivity.kt | 6 + .../screens/common/SecureModalBottomSheet.kt | 50 +++++ .../components/DetailsBottomSheet.kt | 9 +- .../components/PasswordHistorySheet.kt | 7 +- .../components/PasswordSettingsSheet.kt | 9 +- .../components/PreviewBottomSheet.kt | 12 +- .../settings_screen/SettingsContract.kt | 4 +- .../settings_screen/SettingsViewModel.kt | 2 +- .../components/AnalysisBottomSheet.kt | 9 +- .../components/LanguageBottomSheet.kt | 102 +--------- .../bhardwaj/passkey/SettingsViewModelTest.kt | 8 +- scripts/check-modal-bottom-sheet.sh | 19 ++ 17 files changed, 442 insertions(+), 146 deletions(-) create mode 100644 app/src/androidTest/java/com/bhardwaj/passkey/AppUpdateControllerTest.kt create mode 100644 app/src/androidTest/java/com/bhardwaj/passkey/LocalizationCoverageTest.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/model/AppLanguage.kt delete mode 100644 app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt create mode 100644 app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/SecureModalBottomSheet.kt create mode 100755 scripts/check-modal-bottom-sheet.sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0998a69..a296a22 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -119,6 +119,20 @@ android { } } +/** + * Fails the build if a bottom sheet is created without FLAG_SECURE. + * + * Wired to `check` rather than left as a script: a guard nobody runs is not a guard. + */ +val checkSecureBottomSheets by tasks.registering(Exec::class) { + group = "verification" + description = "Fails if ModalBottomSheet is called outside SecureModalBottomSheet." + workingDir = rootDir + commandLine("sh", "scripts/check-modal-bottom-sheet.sh") +} + +tasks.named("check") { dependsOn(checkSecureBottomSheets) } + dependencies { // Android Essentials implementation(libs.androidx.core.ktx) diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/AppUpdateControllerTest.kt b/app/src/androidTest/java/com/bhardwaj/passkey/AppUpdateControllerTest.kt new file mode 100644 index 0000000..fbb99f7 --- /dev/null +++ b/app/src/androidTest/java/com/bhardwaj/passkey/AppUpdateControllerTest.kt @@ -0,0 +1,175 @@ +package com.bhardwaj.passkey + +import android.app.Activity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.bhardwaj.passkey.utils.AppUpdateController +import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager +import com.google.android.play.core.install.model.AppUpdateType +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives the update gate against Play's own fake. + * + * This is the one part of the app that cannot be exercised for real: in-app updates do nothing + * for a locally installed build. It is also the mechanism a hotfix depends on, and the bug it + * replaced was precisely a silent fall-through - an empty registerForActivityResult callback, so + * cancelling a "required" update walked straight into the vault. A test that only checked the + * happy path would not have caught that, so the assertions below are mostly about refusal. + */ +@RunWith(AndroidJUnit4::class) +class AppUpdateControllerTest { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.targetContext + + private lateinit var fake: FakeAppUpdateManager + private lateinit var controller: AppUpdateController + + /** The controller only ever hands this to Play; nothing here needs a real Activity result. */ + private val launcher = object : ActivityResultLauncher() { + override fun launch(input: IntentSenderRequest, options: androidx.core.app.ActivityOptionsCompat?) = Unit + override fun unregister() = Unit + override val contract: ActivityResultContract + get() = ActivityResultContracts.StartIntentSenderForResult() + } + + /** + * Never attached, and never used: the controller hands it to Play, which for the launcher + * overload ignores it. Constructed on the main thread because Activity builds a Handler. + */ + private lateinit var activity: Activity + + @Before + fun setUp() { + instrumentation.runOnMainSync { activity = Activity() } + fake = FakeAppUpdateManager(context) + // Remote Config is null: these tests are about the Play flow. The version floor is a + // separate mechanism and is asserted through onImmediateUpdateNotCompleted below. + controller = AppUpdateController(fake, remoteConfig = null) + } + + private fun check() = instrumentation.runOnMainSync { + controller.checkForUpdate(activity, launcher) + } + + /** + * The controller reacts to Play through addOnSuccessListener, so asserting straight after + * the call is a race - one this test lost only under the full suite, not on its own. + */ + private fun awaitUntil(what: String, condition: () -> Boolean) { + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline) { + if (condition()) return + Thread.sleep(25) + } + throw AssertionError("timed out waiting for: $what") + } + + @Test + fun a_high_priority_update_blocks_immediately() { + fake.setUpdateAvailable(BuildConfig.VERSION_CODE + 1) + fake.setUpdatePriority(AppUpdateController.PRIORITY_FORCE_IMMEDIATE) + + check() + + awaitUntil("the immediate flow to be shown") { fake.isImmediateFlowVisible } + assertThat(fake.typeForUpdateInProgress).isEqualTo(AppUpdateType.IMMEDIATE) + } + + @Test + fun rejecting_a_required_update_does_not_let_the_user_through() { + // The actual bug: the result callback was empty, so a back press dismissed Play's + // dismissible "immediate" UI and the app carried on into the vault. + fake.setUpdateAvailable(BuildConfig.VERSION_CODE + 1) + fake.setUpdatePriority(AppUpdateController.PRIORITY_FORCE_IMMEDIATE) + check() + awaitUntil("the immediate flow to be shown") { fake.isImmediateFlowVisible } + + fake.userRejectsUpdate() + instrumentation.runOnMainSync { controller.onImmediateUpdateNotCompleted() } + + assertThat(controller.state.value).isEqualTo(AppUpdateController.State.UpdateRequired) + } + + @Test + fun a_low_priority_update_does_not_interrupt_anyone() { + fake.setUpdateAvailable(BuildConfig.VERSION_CODE + 1) + fake.setUpdatePriority(0) + fake.setClientVersionStalenessDays(0) + + check() + settle() + + assertThat(fake.isImmediateFlowVisible).isFalse() + assertThat(fake.isConfirmationDialogVisible).isFalse() + assertThat(controller.state.value).isEqualTo(AppUpdateController.State.Idle) + } + + @Test + fun a_stale_low_priority_update_is_offered_flexibly_instead() { + fake.setUpdateAvailable(BuildConfig.VERSION_CODE + 1, AppUpdateType.FLEXIBLE) + fake.setUpdatePriority(0) + fake.setClientVersionStalenessDays(AppUpdateController.FLEXIBLE_STALENESS_DAYS) + + check() + + awaitUntil("the flexible flow to start") { + fake.typeForUpdateInProgress == AppUpdateType.FLEXIBLE + } + // Flexible means the user keeps using the app; nothing is blocked. + assertThat(controller.state.value).isEqualTo(AppUpdateController.State.Idle) + } + + @Test + fun a_downloaded_flexible_update_asks_for_a_restart_rather_than_forcing_one() { + fake.setUpdateAvailable(BuildConfig.VERSION_CODE + 1, AppUpdateType.FLEXIBLE) + fake.setUpdatePriority(0) + fake.setClientVersionStalenessDays(AppUpdateController.FLEXIBLE_STALENESS_DAYS) + + instrumentation.runOnMainSync { + controller.onStart() + controller.checkForUpdate(activity, launcher) + } + awaitUntil("the flexible flow to start") { + fake.typeForUpdateInProgress == AppUpdateType.FLEXIBLE + } + instrumentation.runOnMainSync { + fake.userAcceptsUpdate() + fake.downloadStarts() + fake.downloadCompletes() + } + + awaitUntil("the restart prompt") { + controller.state.value == AppUpdateController.State.ReadyToInstall + } + instrumentation.runOnMainSync { controller.onStop() } + } + + @Test + fun no_update_available_changes_nothing() { + fake.setUpdateNotAvailable() + + check() + settle() + + assertThat(fake.isImmediateFlowVisible).isFalse() + assertThat(controller.state.value).isEqualTo(AppUpdateController.State.Idle) + } + + /** + * Asserting that nothing happened needs the listener to have had its chance to make it + * happen, otherwise the test passes for the wrong reason. + */ + private fun settle() { + Thread.sleep(500) + instrumentation.runOnMainSync { } + } +} diff --git a/app/src/androidTest/java/com/bhardwaj/passkey/LocalizationCoverageTest.kt b/app/src/androidTest/java/com/bhardwaj/passkey/LocalizationCoverageTest.kt new file mode 100644 index 0000000..df4e25e --- /dev/null +++ b/app/src/androidTest/java/com/bhardwaj/passkey/LocalizationCoverageTest.kt @@ -0,0 +1,110 @@ +package com.bhardwaj.passkey + +import android.content.Context +import android.content.res.Configuration +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.bhardwaj.passkey.domain.model.AppLanguage +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test +import org.junit.runner.RunWith +import org.xmlpull.v1.XmlPullParser +import java.util.Locale + +/** + * The regression guard for the bug this whole release started from. + * + * Lint's MissingTranslation catches a key absent from a `values-*` folder. It cannot catch a + * language the picker offers that has no folder at all, a folder the picker never offers, or a + * locale missing from locales_config - which is exactly what happened to Arabic: it was in the + * picker and in `values-ar/`, absent from locales_config, and choosing it silently did nothing. + * + * So these assertions are about the three lists agreeing with [AppLanguage], and about each + * language actually resolving to its own strings at runtime. + */ +@RunWith(AndroidJUnit4::class) +class LocalizationCoverageTest { + + private val context: Context = + InstrumentationRegistry.getInstrumentation().targetContext + + /** + * Keys chosen because they are short, present since before this work, and unmistakably + * different once translated - not because they are the only ones that matter. + */ + private val representativeKeys = listOf( + R.string.skip, + R.string.next, + R.string.change_language, + R.string.privacy, + R.string.delete + ) + + /** + * Translations that legitimately match the English word. + * + * Kept as an explicit list rather than by weakening the assertion: a new coincidence should + * make someone look at it and decide, not pass silently. + */ + private val sameAsEnglish = setOf(AppLanguage.ITALIAN to R.string.privacy) + + private fun resourcesFor(language: AppLanguage) = context + .createConfigurationContext( + Configuration(context.resources.configuration).apply { + setLocale(Locale.forLanguageTag(language.tag)) + } + ) + .resources + + @Test + fun every_offered_language_has_its_own_translations() { + val english = resourcesFor(AppLanguage.ENGLISH) + + AppLanguage.entries + .filter { it != AppLanguage.ENGLISH } + .forEach { language -> + val translated = resourcesFor(language) + val untranslated = representativeKeys.filter { key -> + translated.getString(key) == english.getString(key) && + (language to key) !in sameAsEnglish + } + // A language whose strings all fall back to English has no values- folder, or + // one that was never filled in. Either way the picker is lying about it. + assertWithMessage( + "keys still English in ${language.englishName} (${language.tag})" + ).that(untranslated.map { english.getString(it) }).isEmpty() + } + } + + @Test + fun locales_config_lists_exactly_the_languages_the_picker_offers() { + val declared = mutableSetOf() + context.resources.getXml(R.xml.locales_config).use { parser -> + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { + val name = parser.getAttributeValue(ANDROID_NS, "name") + if (name != null) declared += name + } + } + } + + // Both directions: a missing entry breaks per-app language selection silently, and a + // stray one offers a language that has nothing behind it. + assertThat(declared).containsExactlyElementsIn(AppLanguage.entries.map { it.tag }) + } + + @Test + fun the_app_name_is_deliberately_not_translated() { + // It is a brand, and the only string left marked translatable="false". If it ever starts + // differing per locale, that attribute was removed by accident. + AppLanguage.entries.forEach { language -> + assertThat(resourcesFor(language).getString(R.string.app_name)) + .isEqualTo(resourcesFor(AppLanguage.ENGLISH).getString(R.string.app_name)) + } + } + + private companion object { + const val ANDROID_NS = "http://schemas.android.com/apk/res/android" + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/AppLanguage.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/AppLanguage.kt new file mode 100644 index 0000000..b130caf --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/domain/model/AppLanguage.kt @@ -0,0 +1,45 @@ +package com.bhardwaj.passkey.domain.model + +/** + * Every language the app offers, and the single place that decides so. + * + * There used to be three hand-maintained lists: a 17-entry `arrayListOf(Language(...))` declared + * inline inside the picker composable, `res/xml/locales_config.xml`, and the `values-*` folders. + * They drifted - Arabic was in the picker and in `values-ar/` but missing from locales_config, so + * choosing it silently did nothing. That bug was fixed by adding the missing line; this removes + * the shape that allowed it, and LocalizationCoverageTest fails if the three disagree again. + * + * [endonym] is the language's own name for itself, which is what a language picker must show: + * someone who cannot read the current language has to find their own. + */ +enum class AppLanguage( + val tag: String, + val endonym: String, + val englishName: String +) { + ARABIC("ar", "عربي", "Arabic"), + BENGALI("bn", "বাংলা", "Bengali"), + CHINESE("zh", "中文", "Chinese"), + ENGLISH("en", "English", "English"), + FRENCH("fr", "Français", "French"), + GERMAN("de", "Deutsch", "German"), + GUJARATI("gu", "ગુજરાતી", "Gujarati"), + HINDI("hi", "हिन्दी", "Hindi"), + ITALIAN("it", "Italiano", "Italian"), + JAPANESE("ja", "日本語", "Japanese"), + KOREAN("ko", "한국어", "Korean"), + MARATHI("mr", "मराठी", "Marathi"), + PORTUGUESE("pt", "Português", "Portuguese"), + RUSSIAN("ru", "Русский", "Russian"), + SPANISH("es", "Español", "Spanish"), + TAMIL("ta", "தமிழ்", "Tamil"), + TELUGU("te", "తెలుగు", "Telugu"); + + companion object { + /** The base `values/` folder, and the fallback for a tag the app does not offer. */ + val DEFAULT = ENGLISH + + fun fromTagOrDefault(tag: String?): AppLanguage = + entries.firstOrNull { it.tag.equals(tag?.trim(), ignoreCase = true) } ?: DEFAULT + } +} diff --git a/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt b/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt deleted file mode 100644 index 6ec9b49..0000000 --- a/app/src/main/java/com/bhardwaj/passkey/domain/model/Language.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.bhardwaj.passkey.domain.model - -data class Language( - val languageId: String, - val languageName: String, - val languageNameInEnglish: String -) \ No newline at end of file diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt index d47faca..8adf61f 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/MainActivity.kt @@ -1,6 +1,7 @@ package com.bhardwaj.passkey.presentation import com.bhardwaj.passkey.presentation.navigation.NavRoute +import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -100,6 +101,11 @@ class MainActivity : FragmentActivity() { WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE ) + // FLAG_SECURE already blacks out the recents thumbnail, but only as a side effect of + // blocking screen capture. This says so explicitly, so the two survive independently. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setRecentsScreenshotEnabled(false) + } setContent { PassKeyTheme { Surface( diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/SecureModalBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/SecureModalBottomSheet.kt new file mode 100644 index 0000000..f752340 --- /dev/null +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/SecureModalBottomSheet.kt @@ -0,0 +1,50 @@ +package com.bhardwaj.passkey.presentation.screens.common + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.window.SecureFlagPolicy + +/** + * A bottom sheet that always carries FLAG_SECURE. + * + * Every sheet in this app shows vault contents, and each one is a separate window. The Material 3 + * default is SecureFlagPolicy.Inherit, which does currently inherit the flag from MainActivity - + * so this is not a fix for a live leak. It is a guard against one: a default that changes, or a + * sheet hosted off a window that is not secure, would turn screenshots back on silently and + * nothing would say so. + * + * `scripts/check-modal-bottom-sheet.sh` fails the build if ModalBottomSheet is called anywhere + * but here. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecureModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), + // Defaults mirror ModalBottomSheet's own, so adopting the wrapper changes nothing visually. + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + content: @Composable ColumnScope.() -> Unit +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + shape = shape, + containerColor = containerColor, + dragHandle = dragHandle, + properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn), + content = content + ) +} diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt index a17b5a3..e0c8fad 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/DetailsBottomSheet.kt @@ -21,9 +21,6 @@ import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.ui.window.SecureFlagPolicy -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -38,6 +35,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.SecureModalBottomSheet import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PassKeyTextField import com.bhardwaj.passkey.presentation.theme.BebasNeue @@ -58,10 +56,7 @@ fun DetailsBottomSheet( onPasswordSettingsClicked: () -> Unit, ) { val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - properties = ModalBottomSheetProperties( - securePolicy = SecureFlagPolicy.SecureOn - ), + SecureModalBottomSheet( sheetState = sheetState, onDismissRequest = { onDismiss() }, dragHandle = {}, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt index d4cdfce..051c0f0 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordHistorySheet.kt @@ -11,8 +11,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -23,8 +21,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.SecureFlagPolicy import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.SecureModalBottomSheet import com.bhardwaj.passkey.presentation.screens.detail_screen.DetailState import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins @@ -48,8 +46,7 @@ fun PasswordHistorySheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) } - ModalBottomSheet( - properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn), + SecureModalBottomSheet( sheetState = sheetState, onDismissRequest = onDismiss, dragHandle = {}, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt index 798bf50..88688a9 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/detail_screen/components/PasswordSettingsSheet.kt @@ -12,9 +12,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.ui.window.SecureFlagPolicy -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch @@ -30,6 +27,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.SecureModalBottomSheet import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins @@ -43,10 +41,7 @@ fun PasswordSettingsSheet( ) { val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - properties = ModalBottomSheetProperties( - securePolicy = SecureFlagPolicy.SecureOn - ), + SecureModalBottomSheet( sheetState = sheetState, onDismissRequest = { onDismissPasswordSettings() }, dragHandle = { }, diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt index afedf89..8235c82 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/preview_screen/components/PreviewBottomSheet.kt @@ -9,9 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.ui.window.SecureFlagPolicy -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -26,6 +23,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.SecureModalBottomSheet import com.bhardwaj.passkey.presentation.screens.common.PassKeyButton import com.bhardwaj.passkey.presentation.screens.common.PassKeyTextField import com.bhardwaj.passkey.presentation.theme.BebasNeue @@ -42,16 +40,12 @@ fun PreviewBottomSheet( onSave: () -> Unit ) { val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - properties = ModalBottomSheetProperties( - securePolicy = SecureFlagPolicy.SecureOn - ), + SecureModalBottomSheet( sheetState = sheetState, onDismissRequest = { onDismiss() }, dragHandle = {}, shape = RectangleShape, - - ) { + ) { Box( modifier = modifier .fillMaxWidth() diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt index 21f992b..72c3451 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsContract.kt @@ -3,7 +3,7 @@ package com.bhardwaj.passkey.presentation.screens.settings_screen import android.net.Uri import com.bhardwaj.passkey.data.backup.ImportMode import com.bhardwaj.passkey.domain.model.AutoLockTimeout -import com.bhardwaj.passkey.domain.model.Language +import com.bhardwaj.passkey.domain.model.AppLanguage import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.utils.AlertBy import com.bhardwaj.passkey.utils.PasswordAnalysisResult @@ -36,7 +36,7 @@ enum class RecoveryChangeStep { CURRENT_PASSWORD, NEW_PASSWORD } sealed interface SettingsIntent { data object LanguageClicked : SettingsIntent - data class LanguageSelected(val language: Language) : SettingsIntent + data class LanguageSelected(val language: AppLanguage) : SettingsIntent data object PrivacyClicked : SettingsIntent data object TermsClicked : SettingsIntent diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt index 4ad2176..3bd2143 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/SettingsViewModel.kt @@ -99,7 +99,7 @@ class SettingsViewModel @Inject constructor( is SettingsIntent.LanguageSelected -> { transient.update { it.copy(sheet = null) } - viewModelScope.launch { localeManager.apply(intent.language.languageId) } + viewModelScope.launch { localeManager.apply(intent.language.tag) } } SettingsIntent.RateAppClicked -> emit(SettingsEffect.OpenStoreListing) diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt index 69a6c0d..869dd89 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/AnalysisBottomSheet.kt @@ -28,9 +28,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.ui.window.SecureFlagPolicy -import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -51,6 +48,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R +import com.bhardwaj.passkey.presentation.screens.common.SecureModalBottomSheet import com.bhardwaj.passkey.domain.model.Detail import com.bhardwaj.passkey.presentation.theme.BebasNeue import com.bhardwaj.passkey.presentation.theme.Poppins @@ -65,10 +63,7 @@ fun AnalysisBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - properties = ModalBottomSheetProperties( - securePolicy = SecureFlagPolicy.SecureOn - ), + SecureModalBottomSheet( sheetState = sheetState, onDismissRequest = { onDismiss() }, containerColor = MaterialTheme.colorScheme.background diff --git a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt index 28e0620..49186a8 100644 --- a/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt +++ b/app/src/main/java/com/bhardwaj/passkey/presentation/screens/settings_screen/components/LanguageBottomSheet.kt @@ -22,14 +22,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bhardwaj.passkey.R -import com.bhardwaj.passkey.domain.model.Language +import com.bhardwaj.passkey.domain.model.AppLanguage import com.bhardwaj.passkey.presentation.theme.Poppins @Composable fun LanguageItem( modifier: Modifier = Modifier, - language: Language, - onLanguageChange: (Language) -> Unit + language: AppLanguage, + onLanguageChange: (AppLanguage) -> Unit ) { Column( modifier = modifier @@ -40,7 +40,7 @@ fun LanguageItem( ) { Text( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), - text = language.languageName, + text = language.endonym, fontFamily = Poppins, fontWeight = FontWeight.Bold, fontSize = 16.sp, @@ -48,7 +48,7 @@ fun LanguageItem( ) Text( modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 4.dp), - text = language.languageNameInEnglish, + text = language.englishName, fontFamily = Poppins, fontWeight = FontWeight.Normal, fontSize = 14.sp, @@ -62,96 +62,8 @@ fun LanguageItem( fun LanguageBottomSheet( modifier: Modifier = Modifier, onBackIconClick: () -> Unit, - onLanguageChange: (Language) -> Unit, + onLanguageChange: (AppLanguage) -> Unit, ) { - val languageList = arrayListOf( - Language( - languageId = "ar", - languageName = "عربي", - languageNameInEnglish = "Arabic" - ), - Language( - languageId = "bn", - languageName = "বাংলা", - languageNameInEnglish = "Bengali" - ), - Language( - languageId = "zh", - languageName = "中文", - languageNameInEnglish = "Chinese" - ), - Language( - languageId = "en", - languageName = "English", - languageNameInEnglish = "English" - ), - Language( - languageId = "fr", - languageName = "Français", - languageNameInEnglish = "French" - ), - Language( - languageId = "de", - languageName = "Deutsch", - languageNameInEnglish = "German" - ), - Language( - languageId = "gu", - languageName = "ગુજરાતી", - languageNameInEnglish = "Gujarati" - ), - Language( - languageId = "hi", - languageName = "हिन्दी", - languageNameInEnglish = "Hindi" - ), - Language( - languageId = "it", - languageName = "Italiano", - languageNameInEnglish = "Italian" - ), - Language( - languageId = "ja", - languageName = "日本語", - languageNameInEnglish = "Japanese" - ), - Language( - languageId = "ko", - languageName = "한국어", - languageNameInEnglish = "Korean" - ), - Language( - languageId = "mr", - languageName = "मराठी", - languageNameInEnglish = "Marathi" - ), - Language( - languageId = "pt", - languageName = "Português", - languageNameInEnglish = "Portuguese" - ), - Language( - languageId = "ru", - languageName = "Русский", - languageNameInEnglish = "Russian" - ), - Language( - languageId = "es", - languageName = "Español", - languageNameInEnglish = "Spanish" - ), - Language( - languageId = "ta", - languageName = "தமிழ்", - languageNameInEnglish = "Tamil" - ), - Language( - languageId = "te", - languageName = "తెలుగు", - languageNameInEnglish = "Telugu" - ), - ) - Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) { Icon( modifier = Modifier @@ -184,7 +96,7 @@ fun LanguageBottomSheet( color = MaterialTheme.colorScheme.onBackground ) LazyColumn { - items(languageList) { single -> + items(AppLanguage.entries) { single -> LanguageItem(language = single, onLanguageChange = onLanguageChange) } } diff --git a/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt b/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt index b44b485..8be88e8 100644 --- a/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt +++ b/app/src/test/java/com/bhardwaj/passkey/SettingsViewModelTest.kt @@ -12,7 +12,7 @@ import com.bhardwaj.passkey.data.locale.AppLocaleManager import com.bhardwaj.passkey.data.security.DatabaseKeyManager import com.bhardwaj.passkey.domain.model.AutoLockTimeout import com.bhardwaj.passkey.domain.model.Category -import com.bhardwaj.passkey.domain.model.Language +import com.bhardwaj.passkey.domain.model.AppLanguage import com.bhardwaj.passkey.domain.repository.PreferencesRepository import com.bhardwaj.passkey.presentation.navigation.NavRoute import com.bhardwaj.passkey.presentation.screens.settings_screen.RecoveryChangeStep @@ -104,11 +104,7 @@ class SettingsViewModelTest { vm.state.test { awaitItem() vm.onIntent(SettingsIntent.LanguageClicked) - vm.onIntent( - SettingsIntent.LanguageSelected( - Language(languageId = "hi", languageName = "हिन्दी", languageNameInEnglish = "Hindi") - ) - ) + vm.onIntent(SettingsIntent.LanguageSelected(AppLanguage.HINDI)) assertThat(expectMostRecentItem().sheet).isNull() cancelAndIgnoreRemainingEvents() } diff --git a/scripts/check-modal-bottom-sheet.sh b/scripts/check-modal-bottom-sheet.sh new file mode 100755 index 0000000..8937fc1 --- /dev/null +++ b/scripts/check-modal-bottom-sheet.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Fails if ModalBottomSheet is called outside SecureModalBottomSheet. +# +# Every sheet shows vault contents and every sheet is its own window, so each one has to carry +# FLAG_SECURE. Relying on review to remember that is how a screenshot-blocking hole gets added +# by someone doing something else. +set -e +WRAPPER='app/src/main/java/com/bhardwaj/passkey/presentation/screens/common/SecureModalBottomSheet.kt' +# The leading boundary is what stops this matching the wrapper's own name. +offenders=$(grep -rlnE '(^|[^A-Za-z])ModalBottomSheet\(' app/src/main --include='*.kt' \ + | grep -v "^$WRAPPER$" || true) +if [ -n "$offenders" ]; then + echo "ModalBottomSheet called outside SecureModalBottomSheet:" + echo "$offenders" | sed 's/^/ /' + echo + echo "Use SecureModalBottomSheet instead - it pins SecureFlagPolicy.SecureOn." + exit 1 +fi +echo "OK: every bottom sheet goes through SecureModalBottomSheet."