Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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}"
Expand Down
12 changes: 10 additions & 2 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,13 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile

-keep class net.sqlcipher.** { *; }
-keep class net.sqlcipher.database.* { *; }
# 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,28 @@ import kotlinx.coroutines.launch

data class OnBoardingScreen(
val title: String,
val description: String,
val highlightedText: List<String>
val description: String
)

@Composable
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)
)
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HighlightSpan>)

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<HighlightSpan>()
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
) {
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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
Expand All @@ -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(
Expand Down
19 changes: 13 additions & 6 deletions app/src/main/java/com/bhardwaj/passkey/utils/Categories.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
package com.bhardwaj.passkey.utils

enum class Categories {
BANKS,
MAILS,
APPS,
OTHERS
}
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)
}
Loading