From ccc8843cb09a8673f3d126dd61a177231ef470e6 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Wed, 16 Sep 2026 08:05:18 +0530 Subject: [PATCH 01/11] 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/11] 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 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

K7UpUK$>l@ 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 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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