diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt index 300f0d48..26cc1939 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/ui/UpgradeScreen.kt @@ -117,12 +117,10 @@ internal fun UpgradeScreen( title = if (view == FossUpgradeView.PITCH) { AnnotatedString(stringResource(R.string.settings_upgrade_status_label)) } else { - // "CAPod FOSS", not "CAPod Pro": on FOSS the flavor name IS the brand. The upgraded - // gate keeps the highlight for supporters only. - upgradeScreenTitle( - upgraded = view == FossUpgradeView.STATUS_UPGRADED, - nameRes = R.string.app_name_foss, - ) + // "CAPod FOSS", not "CAPod Pro": the FOSS flavor's own qualifier resource supplies the + // tier word, so the title names this build. The upgraded gate keeps the highlight for + // supporters only. + upgradeScreenTitle(upgraded = view == FossUpgradeView.STATUS_UPGRADED) }, onNavigateUp = onNavigateUp, snackbarHostState = snackbarHostState, diff --git a/app/src/foss/res/values/strings.xml b/app/src/foss/res/values/strings.xml index 456deeb9..6abfb79f 100644 --- a/app/src/foss/res/values/strings.xml +++ b/app/src/foss/res/values/strings.xml @@ -8,6 +8,9 @@ No ads. No tracking. No Google Play lock-in. Back already? Your support keeps CAPod alive. FOSS + + %1$s %2$s Supporter since %s Thank you for supporting CAPod\'s development! Open sponsor page diff --git a/app/src/gplay/res/values/strings.xml b/app/src/gplay/res/values/strings.xml index b9cd5192..4d0880ee 100644 --- a/app/src/gplay/res/values/strings.xml +++ b/app/src/gplay/res/values/strings.xml @@ -10,6 +10,9 @@ Google Play reports that you already own this upgrade, but it couldn\'t be restored. Make sure you are using the Google account you purchased with. Play Store synchronization may take time — try rebooting, clearing the Google Play cache or simply waiting. Restore purchase Pro + + %1$s %2$s Get %1$s CAPod is developed by a single person. Upgrading unlocks extra features and helps keep the app alive. diff --git a/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt b/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt index 42722af6..31cdcb9e 100644 --- a/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt +++ b/app/src/main/java/eu/darken/capod/common/upgrade/ui/UpgradeContent.kt @@ -92,35 +92,68 @@ internal object UpgradeScreenTags { const val HERO = "upgrade_hero" } -// Composed app title with the flavor postfix highlighted in the upgraded color while Pro is -// active — the same treatment the dashboard title card uses. +// The app's brand title, composed through the flavor's title template so translators own the word +// order and punctuation instead of the code assuming "name, space, qualifier". Replaces a split on +// spaces that required exactly two tokens: Arabic ("كابود – النسخة الاحترافية") had four and lost +// its branding entirely, while Estonian puts the qualifier FIRST ("Tasuline CAPod") and so passed +// the token count while highlighting the wrong word. +// +// The two flags are deliberately separate. `includeQualifier` decides whether the tier word is part +// of the title at all (the toolbar drops it while free); `highlightQualifier` only decides whether +// it is colored. +// +// `highlightColor` exists because capod tints by flavor: the toolbar paints FOSS on brand_secondary +// and Pro on brand_tertiary, so the color cannot be baked in the way the upstream app bakes it. @Composable -internal fun upgradeScreenTitle( - upgraded: Boolean, - @StringRes nameRes: Int = R.string.app_name_pro, +internal fun brandTitle( + includeQualifier: Boolean, + highlightQualifier: Boolean, + highlightColor: Color = colorResource(R.color.brand_tertiary), ): AnnotatedString { - // capod ships the composed "CAPod Pro" as one translatable string so translations can reorder - // the words; the postfix is the trailing part and gets the upgraded highlight. FOSS passes its - // own "CAPod FOSS" instead — the flavor name is the brand there, Pro is not a thing. - val parts = stringResource(nameRes).split(" ").filter { it.isNotEmpty() } - val highlight = colorResource(R.color.brand_tertiary) - return buildAnnotatedString { - if (parts.size == 2) { - append("${parts[0]} ") - if (upgraded) pushStyle(SpanStyle(color = highlight)) - append(parts[1]) - if (upgraded) pop() - } else { - append(stringResource(nameRes)) - } + val name = AnnotatedString(stringResource(R.string.app_name)) + if (!includeQualifier) return name + + val qualifier = buildAnnotatedString { + if (highlightQualifier) pushStyle(SpanStyle(color = highlightColor)) + append(stringResource(R.string.upgrade_badge_label)) + if (highlightQualifier) pop() } + return spliceTitleTemplate( + formatted = stringResource( + R.string.app_name_upgraded_template, + BRAND_TITLE_MARKER, + BRAND_QUALIFIER_MARKER, + ), + name = name, + qualifier = qualifier, + ) } +// Same composition for call sites that need a plain String. Routed through brandTitle so the two +// forms cannot drift apart. +@Composable +internal fun brandTitleText(includeQualifier: Boolean): String = + brandTitle(includeQualifier = includeQualifier, highlightQualifier = false).text + +// Composed app title with the flavor qualifier highlighted in the upgraded color while Pro is +// active — the same treatment the toolbar uses. +@Composable +internal fun upgradeScreenTitle(upgraded: Boolean): AnnotatedString = brandTitle( + // Unconditional: this title names the flavor even when the screen is showing the free state. + includeQualifier = true, + highlightQualifier = upgraded, +) + // Marker char for brand-title splicing: formatted into the translated pattern via the normal // Android format path (so %1$s vs %s, argument reordering, and %% all behave), then replaced // with the styled brand. U+FFFC (object replacement) cannot occur in a real translation. internal const val BRAND_TITLE_MARKER = "" +// The title template's second slot. U+FFF9 (interlinear annotation anchor) is likewise absent from +// real translations, and being distinct from BRAND_TITLE_MARKER is what lets the splice tell the +// two slots apart after the formatter has reordered them. +internal const val BRAND_QUALIFIER_MARKER = "" + internal fun spliceBrandTitle(formatted: String, brand: AnnotatedString): AnnotatedString = buildAnnotatedString { var rest = formatted var found = false @@ -140,6 +173,71 @@ internal fun spliceBrandTitle(formatted: String, brand: AnnotatedString): Annota } } +// Splices the two title slots into an already-formatted template. Stricter than spliceBrandTitle on +// purpose: that one splices a brand into a *sentence*, where a repeated marker is a legitimate (if +// odd) translation. A *title* template has exactly two slots, so anything else is damage — and once +// a slot is missing or doubled the template can no longer tell us the intended order or +// punctuation, which is the whole reason it exists. So a broken template is discarded whole and the +// default title is rebuilt from the parts; patching it up piecewise would emit a title no +// translator wrote. +internal fun spliceTitleTemplate( + formatted: String, + name: AnnotatedString, + qualifier: AnnotatedString, +): AnnotatedString { + val slots = listOf( + BRAND_TITLE_MARKER to name, + BRAND_QUALIFIER_MARKER to qualifier, + ).map { (marker, value) -> Triple(formatted.indexOf(marker), marker, value) } + + val intact = slots.all { (index, marker, _) -> + index >= 0 && formatted.indexOf(marker, index + marker.length) < 0 + } + if (!intact) { + return buildAnnotatedString { + append(name) + append(" ") + append(qualifier) + } + } + + return buildAnnotatedString { + var cursor = 0 + slots.sortedBy { it.first }.forEach { (index, marker, value) -> + append(formatted.substring(cursor, index)) + append(value) + cursor = index + marker.length + } + append(formatted.substring(cursor)) + } +} + +// All three flag combinations the app actually uses, in one place — the pair (true, false) is the +// one that reads as a mistake at a glance, so seeing it render the qualifier in plain text is what +// documents it. +@Preview2 +@Composable +private fun BrandTitlePreview() { + PreviewWrapper { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = brandTitle(includeQualifier = false, highlightQualifier = false)) + Text(text = brandTitle(includeQualifier = true, highlightQualifier = false)) + Text(text = brandTitle(includeQualifier = true, highlightQualifier = true)) + Text( + text = brandTitle( + includeQualifier = true, + highlightQualifier = true, + highlightColor = colorResource(R.color.brand_secondary), + ), + ) + Text(text = brandTitleText(includeQualifier = true)) + } + } +} + @Composable internal fun UpgradeScreenScaffold( @StringRes titleRes: Int, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index eb9c32fd..72714786 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -35,9 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel @@ -54,6 +51,7 @@ import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.common.upgrade.ui.brandTitle import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.main.ui.overview.cards.BackgroundMonitoringOffCard import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard @@ -613,35 +611,18 @@ private fun OverviewScreenMonitoringOffPreview() = PreviewWrapper { @Composable private fun ToolbarTitle(upgradeInfo: UpgradeRepo.Info) { - val appName = stringResource(R.string.app_name) - val proName = stringResource(R.string.app_name_pro) - val fossName = stringResource(R.string.app_name_foss) - - val titleParts = when (upgradeInfo.type) { - UpgradeRepo.Type.GPLAY -> { - if (upgradeInfo.isPro) proName else appName - } - - UpgradeRepo.Type.FOSS -> { - if (upgradeInfo.isPro) fossName else appName - } - }.split(" ").filter { it.isNotEmpty() } - - if (titleParts.size == 2) { - val suffixColor = when (upgradeInfo.type) { - UpgradeRepo.Type.FOSS -> colorResource(R.color.brand_secondary) - else -> colorResource(R.color.brand_tertiary) - } - - Text( - text = buildAnnotatedString { - append("${titleParts[0]} ") - withStyle(SpanStyle(color = suffixColor)) { - append(titleParts[1]) - } - }, - ) - } else { - Text(text = appName) + // The tier word and its wording come from the flavor's own resources, so the title no longer + // needs a per-type string lookup — only the tint still differs between the two. + val highlight = when (upgradeInfo.type) { + UpgradeRepo.Type.FOSS -> colorResource(R.color.brand_secondary) + UpgradeRepo.Type.GPLAY -> colorResource(R.color.brand_tertiary) } + + Text( + text = brandTitle( + includeQualifier = upgradeInfo.isPro, + highlightQualifier = upgradeInfo.isPro, + highlightColor = highlight, + ), + ) } diff --git a/app/src/test/java/eu/darken/capod/common/upgrade/ui/BrandTitleTest.kt b/app/src/test/java/eu/darken/capod/common/upgrade/ui/BrandTitleTest.kt new file mode 100644 index 00000000..ed8579a7 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/upgrade/ui/BrandTitleTest.kt @@ -0,0 +1,103 @@ +package eu.darken.capod.common.upgrade.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.test.core.app.ApplicationProvider +import eu.darken.capod.R +import eu.darken.capod.common.compose.PreviewWrapper +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain +import org.junit.Test +import testhelpers.compose.BaseComposeRobolectricTest + +/** + * Resolves the real flavor resources rather than a sample pattern, so this also proves the two + * markers survive Android's format path and never reach the user. + * + * Flavor-agnostic on purpose: it asserts against whatever this variant's qualifier resource says + * ("Pro" on GPLAY, "FOSS" on FOSS) so the one test guards both. The resources are flavor-owned, so + * a variant that compiles proves nothing about the other. + */ +class BrandTitleTest : BaseComposeRobolectricTest() { + + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private val name: String + get() = context.getString(R.string.app_name) + + private val qualifier: String + get() = context.getString(R.string.upgrade_badge_label) + + private val composed: String + get() = context.getString(R.string.app_name_upgraded_template, name, qualifier) + + private fun capture(block: @Composable () -> AnnotatedString): AnnotatedString { + lateinit var captured: AnnotatedString + composeRule.setContent { + PreviewWrapper { captured = block() } + } + composeRule.waitForIdle() + return captured + } + + @Test + fun `without the qualifier the title is the bare app name`() { + val result = capture { brandTitle(includeQualifier = false, highlightQualifier = false) } + + result.text shouldBe name + result.spanStyles.size shouldBe 0 + } + + // The regression guard for the two-flag split: the qualifier is present but NOT colored. + // Collapsing the flags drops it; highlighting on `includeQualifier` alone colors it. Both would + // still produce plausible-looking text, so the span count is the assertion that matters. + @Test + fun `an included but unhighlighted qualifier is present and carries no span`() { + val result = capture { brandTitle(includeQualifier = true, highlightQualifier = false) } + + result.text shouldBe composed + result.text.contains(qualifier) shouldBe true + result.spanStyles.size shouldBe 0 + } + + @Test + fun `a highlighted qualifier carries exactly one span covering the qualifier only`() { + val result = capture { brandTitle(includeQualifier = true, highlightQualifier = true) } + + result.text shouldBe composed + result.spanStyles.size shouldBe 1 + val span = result.spanStyles.single() + // Not just "a span exists" — the bug class this replaces put the highlight on the app name + // while rendering perfectly correct text. + result.text.substring(span.start, span.end) shouldBe qualifier + } + + // The markers are injected as format arguments, so a template or formatter that mangled them + // would leak U+FFFC / U+FFF9 into the toolbar. + @Test + fun `neither splice marker survives into the rendered title`() { + val result = capture { brandTitle(includeQualifier = true, highlightQualifier = true) } + + result.text shouldNotContain BRAND_TITLE_MARKER + result.text shouldNotContain BRAND_QUALIFIER_MARKER + } + + @Test + fun `the string form matches the annotated form`() { + val result = capture { AnnotatedString(brandTitleText(includeQualifier = true)) } + + result.text shouldBe composed + } + + // upgradeScreenTitle is the thin wrapper both upgrade screens title themselves with: it must + // keep naming the flavor even while the screen shows the free state. + @Test + fun `the upgrade screen title keeps the qualifier when not upgraded`() { + val result = capture { upgradeScreenTitle(upgraded = false) } + + result.text shouldBe composed + result.spanStyles.size shouldBe 0 + } +} diff --git a/app/src/test/java/eu/darken/capod/common/upgrade/ui/TitleTemplateSpliceTest.kt b/app/src/test/java/eu/darken/capod/common/upgrade/ui/TitleTemplateSpliceTest.kt new file mode 100644 index 00000000..750948d6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/upgrade/ui/TitleTemplateSpliceTest.kt @@ -0,0 +1,159 @@ +package eu.darken.capod.common.upgrade.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +/** + * The title template lets translators own word order and punctuation, so the styled qualifier has + * to land on the right offsets wherever they put it. Assertions are on span boundaries, not just on + * the concatenated text: the Estonian defect this replaces rendered the correct characters with the + * highlight sitting on the wrong word. + */ +class TitleTemplateSpliceTest : BaseTest() { + + private val qualifierColor = Color.Red + + private val name = AnnotatedString("CAPod") + + private val qualifier: AnnotatedString = buildAnnotatedString { + pushStyle(SpanStyle(color = qualifierColor)) + append("Pro") + pop() + } + + private fun template(pattern: String) = pattern + .replace("%1\$s", BRAND_TITLE_MARKER) + .replace("%2\$s", BRAND_QUALIFIER_MARKER) + + @Test + fun `the default order highlights the trailing qualifier`() { + val result = spliceTitleTemplate(template("%1\$s %2\$s"), name, qualifier) + + result.text shouldBe "CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().item.color shouldBe qualifierColor + result.spanStyles.single().start shouldBe 6 + result.spanStyles.single().end shouldBe 9 + result.text.substring(6, 9) shouldBe "Pro" + } + + // Estonian: "Tasuline CAPod". The old split-on-space code passed its two-token guard here and + // then styled the SECOND token, highlighting the brand instead of the tier. + @Test + fun `a reordered template highlights the leading qualifier`() { + val result = spliceTitleTemplate(template("%2\$s %1\$s"), name, qualifier) + + result.text shouldBe "Pro CAPod" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 0 + result.spanStyles.single().end shouldBe 3 + result.text.substring(0, 3) shouldBe "Pro" + } + + @Test + fun `a custom separator shifts the qualifier without entering the span`() { + val result = spliceTitleTemplate(template("%1\$s – %2\$s"), name, qualifier) + + result.text shouldBe "CAPod – Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 8 + result.spanStyles.single().end shouldBe 11 + result.text.substring(8, 11) shouldBe "Pro" + } + + // Arabic: "كابود – النسخة الاحترافية". Four space-separated tokens, so the old code's guard + // failed and dropped the branding entirely rather than mis-styling it. + @Test + fun `a multi-word qualifier is highlighted whole`() { + val multiWord = buildAnnotatedString { + pushStyle(SpanStyle(color = qualifierColor)) + append("النسخة الاحترافية") + pop() + } + + val result = spliceTitleTemplate(template("%1\$s – %2\$s"), AnnotatedString("كابود"), multiWord) + + result.text shouldBe "كابود – النسخة الاحترافية" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 8 + result.spanStyles.single().end shouldBe 25 + result.text.substring(8, 25) shouldBe "النسخة الاحترافية" + } + + // Span offsets are UTF-16 indices, so a supplementary character ahead of a slot shifts it by + // two. Pins that the splice arithmetic counts code units and not code points. + @Test + fun `a supplementary character before the slots shifts the span by two`() { + val result = spliceTitleTemplate(template("🎧 %1\$s %2\$s"), name, qualifier) + + result.text shouldBe "🎧 CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 9 + result.spanStyles.single().end shouldBe 12 + result.text.substring(9, 12) shouldBe "Pro" + } + + @Test + fun `a duplicated name marker falls back to the complete default title`() { + val result = spliceTitleTemplate( + "$BRAND_TITLE_MARKER $BRAND_TITLE_MARKER $BRAND_QUALIFIER_MARKER", + name, + qualifier, + ) + + result.text shouldBe "CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 6 + result.spanStyles.single().end shouldBe 9 + } + + @Test + fun `a duplicated qualifier marker falls back to the complete default title`() { + val result = spliceTitleTemplate( + "$BRAND_TITLE_MARKER $BRAND_QUALIFIER_MARKER $BRAND_QUALIFIER_MARKER", + name, + qualifier, + ) + + result.text shouldBe "CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 6 + result.spanStyles.single().end shouldBe 9 + } + + @Test + fun `a missing name marker falls back rather than rendering the qualifier alone`() { + val result = spliceTitleTemplate("Get $BRAND_QUALIFIER_MARKER", name, qualifier) + + result.text shouldBe "CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 6 + result.spanStyles.single().end shouldBe 9 + } + + @Test + fun `a missing qualifier marker falls back rather than dropping the tier`() { + val result = spliceTitleTemplate("Get $BRAND_TITLE_MARKER", name, qualifier) + + result.text shouldBe "CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().start shouldBe 6 + result.spanStyles.single().end shouldBe 9 + } + + @Test + fun `a template with neither marker falls back to the complete default title`() { + val result = spliceTitleTemplate("CAPod Pro", name, qualifier) + + result.text shouldBe "CAPod Pro" + result.spanStyles.size shouldBe 1 + result.spanStyles.single().item.color shouldBe qualifierColor + result.spanStyles.single().start shouldBe 6 + result.spanStyles.single().end shouldBe 9 + } +} diff --git a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt index 4ee8525c..6e7b4355 100644 --- a/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt +++ b/app/src/testFoss/java/eu/darken/capod/common/upgrade/ui/FossUpgradeScreenTest.kt @@ -12,6 +12,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.compose.ui.semantics.SemanticsActions import eu.darken.capod.R import eu.darken.capod.common.compose.PreviewWrapper +import io.kotest.matchers.shouldBe import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -26,6 +27,15 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() { private val context: Context get() = ApplicationProvider.getApplicationContext() + // "CAPod FOSS" — the composed flavor title, built the way production builds it: the app name + // through the FOSS title template, with the flavor's own qualifier resource. + private val composedTitle: String + get() = context.getString( + R.string.app_name_upgraded_template, + context.getString(R.string.app_name), + context.getString(R.string.upgrade_badge_label), + ) + @Test fun `renders redesigned foss content without duplicated app bar title`() { composeRule.setUpgradeContent { @@ -70,9 +80,10 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() { UpgradeScreen(view = FossUpgradeView.STATUS_FREE) } - // "CAPod FOSS", not "CAPod Pro": the status views describe a FOSS install. - composeRule.onAllNodesWithText(context.getString(R.string.app_name_foss)).assertCountEquals(1) - composeRule.onAllNodesWithText(context.getString(R.string.app_name_pro)).assertCountEquals(0) + // "CAPod FOSS", not "CAPod Pro": the status views describe a FOSS install, and the title + // takes its qualifier from the FOSS flavor's own resource. + composeRule.onAllNodesWithText(composedTitle).assertCountEquals(1) + context.getString(R.string.upgrade_badge_label) shouldBe "FOSS" composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_FREE).assertCountEquals(1) composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SHOW_OPTIONS).assertCountEquals(1) composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_SPONSOR).assertCountEquals(0) @@ -107,7 +118,7 @@ class FossUpgradeScreenTest : BaseComposeRobolectricTest() { UpgradeScreen(view = FossUpgradeView.STATUS_UPGRADED, supporterSince = since) } - composeRule.onAllNodesWithText(context.getString(R.string.app_name_foss)).assertCountEquals(1) + composeRule.onAllNodesWithText(composedTitle).assertCountEquals(1) composeRule.onAllNodesWithTag(UpgradeScreenTags.FOSS_STATUS_UPGRADED).assertCountEquals(1) composeRule.onAllNodesWithText(context.getString(R.string.upgrade_foss_supporter_thanks)) .assertCountEquals(1) diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleLocaleTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleLocaleTest.kt new file mode 100644 index 00000000..3557b53d --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleLocaleTest.kt @@ -0,0 +1,106 @@ +package eu.darken.capod.common.upgrade.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.test.core.app.ApplicationProvider +import eu.darken.capod.R +import eu.darken.capod.common.compose.PreviewWrapper +import io.kotest.matchers.shouldBe +import org.junit.Test +import org.robolectric.annotation.Config +import testhelpers.compose.BaseComposeRobolectricTest + +/** + * The two locales that broke the old split-on-space title, resolved through the real translated + * resources rather than a sample pattern. + * + * Both assert on the span boundary, not on text alone: Estonian rendered the correct characters + * with the highlight on the wrong word, so a text-only assertion would have passed throughout. + */ +abstract class BrandTitleLocaleTest : BaseComposeRobolectricTest() { + + protected val context: Context + get() = ApplicationProvider.getApplicationContext() + + protected val name: String + get() = context.getString(R.string.app_name) + + protected val qualifier: String + get() = context.getString(R.string.upgrade_badge_label) + + protected val composed: String + get() = context.getString(R.string.app_name_upgraded_template, name, qualifier) + + protected fun capture(block: @Composable () -> AnnotatedString): AnnotatedString { + lateinit var captured: AnnotatedString + composeRule.setContent { + PreviewWrapper { captured = block() } + } + composeRule.waitForIdle() + return captured + } + + /** + * Arabic composes the title with an en-dash separator and a two-word qualifier + * ("كابود – النسخة الاحترافية"). The old code split on spaces and bailed out unless it saw + * exactly two tokens, so four tokens meant the Pro branding vanished from the toolbar entirely + * and the upgrade screen showed the name uncolored. + */ + @Config(qualifiers = "ar") + class Arabic : BrandTitleLocaleTest() { + + @Test + fun `the multi-word qualifier is present and highlighted whole`() { + val result = capture { upgradeScreenTitle(upgraded = true) } + + result.text shouldBe composed + // The qualifier is genuinely multi-word here — that is what defeated the token count. + qualifier.contains(" ") shouldBe true + result.spanStyles.size shouldBe 1 + val span = result.spanStyles.single() + result.text.substring(span.start, span.end) shouldBe qualifier + } + + @Test + fun `the separator stays outside the highlight`() { + val result = capture { upgradeScreenTitle(upgraded = true) } + + val span = result.spanStyles.single() + // The name and its separator precede the qualifier and must not be colored. + result.text.substring(0, span.start) shouldBe composed.removeSuffix(qualifier) + result.text.substring(0, span.start).contains(name) shouldBe true + } + } + + /** + * Estonian puts the qualifier FIRST ("Tasuline CAPod"). That splits to exactly two tokens, so + * the old guard passed and then styled the second one — highlighting the brand and leaving the + * tier plain. Silently backwards, with no fallback to catch it. + */ + @Config(qualifiers = "et-rEE") + class Estonian : BrandTitleLocaleTest() { + + @Test + fun `the leading qualifier carries the highlight, not the app name`() { + val result = capture { upgradeScreenTitle(upgraded = true) } + + result.text shouldBe composed + result.spanStyles.size shouldBe 1 + val span = result.spanStyles.single() + result.text.substring(span.start, span.end) shouldBe qualifier + } + + @Test + fun `the qualifier really does precede the app name in this locale`() { + val result = capture { upgradeScreenTitle(upgraded = true) } + + // Pins the reordering itself: if the template ever regressed to the default order this + // would still highlight the right word, so without this the locale's whole point is + // untested. + val span = result.spanStyles.single() + span.start shouldBe 0 + result.text.indexOf(name) shouldBe qualifier.length + 1 + } + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleTemplateLocalesTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleTemplateLocalesTest.kt new file mode 100644 index 00000000..2e5f9be8 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/BrandTitleTemplateLocalesTest.kt @@ -0,0 +1,111 @@ +package eu.darken.capod.common.upgrade.ui + +import android.content.Context +import android.content.res.Configuration +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.graphics.Color +import androidx.test.core.app.ApplicationProvider +import eu.darken.capod.R +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.matchers.shouldBe +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import testhelpers.TestApplication +import java.util.Locale + +/** + * Sweeps every shipped locale through the real Android format path. + * + * A translated template is code the formatter executes, not inert text: a stray `%`, a `%3$s` or a + * `%1$d` throws inside `getString` *before* the splice fallback can run, so no amount of defensive + * splicing protects against it. This is the only place that failure mode is caught. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = TestApplication::class) +class BrandTitleTemplateLocalesTest { + + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private fun localized(tag: String): Context = context.createConfigurationContext( + Configuration(context.resources.configuration).apply { setLocale(Locale.forLanguageTag(tag)) }, + ) + + private val locales: List + get() = context.assets.locales.filter { it.isNotBlank() }.sorted() + + // Mirrors brandTitle's composition without Compose, so all locales can be swept cheaply. + private fun compose(ctx: Context): AnnotatedString { + val qualifier = buildAnnotatedString { + pushStyle(SpanStyle(color = Color.Red)) + append(ctx.getString(R.string.upgrade_badge_label)) + pop() + } + return spliceTitleTemplate( + formatted = ctx.getString( + R.string.app_name_upgraded_template, + BRAND_TITLE_MARKER, + BRAND_QUALIFIER_MARKER, + ), + name = AnnotatedString(ctx.getString(R.string.app_name)), + qualifier = qualifier, + ) + } + + // Guards the sweep itself: if locale enumeration ever silently returned one entry, every + // assertion below would still pass while testing nothing. + @Test + fun `the locale sweep actually covers the shipped translations`() { + locales shouldHaveAtLeastSize 60 + } + + @Test + fun `every locale template declares exactly the two title placeholders`() { + val offenders = locales.mapNotNull { tag -> + val template = localized(tag).getString(R.string.app_name_upgraded_template) + val specifiers = FORMAT_SPECIFIER + .findAll(template.replace("%%", "")) + .map { it.value } + .sorted() + .toList() + if (specifiers == listOf("%1\$s", "%2\$s")) null else "$tag -> $template" + } + + offenders shouldBe emptyList() + } + + @Test + fun `every locale resolves to a title that highlights exactly its qualifier`() { + val offenders = locales.mapNotNull { tag -> + val ctx = localized(tag) + val name = ctx.getString(R.string.app_name) + val qualifier = ctx.getString(R.string.upgrade_badge_label) + // Throws here rather than failing an assertion if a template is malformed — which is + // exactly the production failure being guarded against. + val result = compose(ctx) + + val span = result.spanStyles.singleOrNull() + when { + name.isBlank() || qualifier.isBlank() -> "$tag -> blank part" + result.text.contains(BRAND_TITLE_MARKER) -> "$tag -> name marker leaked" + result.text.contains(BRAND_QUALIFIER_MARKER) -> "$tag -> qualifier marker leaked" + !result.text.contains(name) -> "$tag -> name missing from '${result.text}'" + span == null -> "$tag -> expected one span, got ${result.spanStyles.size}" + result.text.substring(span.start, span.end) != qualifier -> + "$tag -> span covers '${result.text.substring(span.start, span.end)}', want '$qualifier'" + + else -> null + } + } + + offenders shouldBe emptyList() + } + + companion object { + private val FORMAT_SPECIFIER = Regex("""%(\d+\$)?[a-zA-Z]""") + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt index c0309b28..1a472041 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt @@ -34,9 +34,15 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() { // capod's hero bodies name the app inline instead of taking a format argument. private fun appNameWithPostfixedHeroBody(bodyRes: Int): String = context.getString(bodyRes) - // "CAPod Pro" — the composed flavor title the screen renders for owners and grace users. + // "CAPod Pro" — the composed flavor title the screen renders for owners and grace users, built + // the way production builds it: the app name through the title template, with the flavor's own + // qualifier resource. private val appNameWithPostfix: String - get() = context.getString(R.string.app_name_pro) + get() = context.getString( + R.string.app_name_upgraded_template, + context.getString(R.string.app_name), + context.getString(R.string.upgrade_badge_label), + ) // What the acquisition top bar must render: the translated pitch pattern with the composed // brand formatted into it. @@ -72,9 +78,9 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() { .fetchSemanticsNode() .config[SemanticsProperties.Text] .single() - // Derived like the production title does it: the postfix is the trailing word of the - // composed brand. - val postfix = appNameWithPostfix.split(" ")[1] + // Read from the qualifier resource, not split back out of the composed title: the template + // is free to put it first or separate it with something other than a space. + val postfix = context.getString(R.string.upgrade_badge_label) rendered.text shouldBe acquisitionTitle rendered.spanStyles.size shouldBe 1