From 2a4a05825beb9b9cc250e27e53aa6fafe65e61f9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:35:46 -0400 Subject: [PATCH 1/2] fix(notifications): post the test push under one fixed id A second test sent while the first is still in the shade joined an Android 16 auto-group (g:Aggregate_AlertingSection), which posted it with FLAG_SILENT, so it never popped. Posting every test under one id replaces the last one instead. Other pushes keep a random id; chats keep their chat id. --- .../notifications/NotificationIdPlanner.kt | 19 ++++++++++++ .../app/notifications/NotificationService.kt | 2 +- .../NotificationIdPlannerTest.kt | 30 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationIdPlanner.kt create mode 100644 apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationIdPlannerTest.kt diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationIdPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationIdPlanner.kt new file mode 100644 index 0000000000..c9c28ffecb --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationIdPlanner.kt @@ -0,0 +1,19 @@ +package com.flipcash.app.notifications + +/** + * The id the test push posts under, every time. A new test replaces the last one in the shade + * rather than posting beside it: Android 16 auto-groups a second notification from the app and + * posts the newcomer silently, so a test sent while the last is still showing would not pop up. + * Replacing the notification still alerts, because the builder does not set `onlyAlertOnce`. + */ +const val TEST_PUSH_NOTIFICATION_ID = 0x7E57 + +/** + * The id a notification that is not a chat posts under: [TEST_PUSH_NOTIFICATION_ID] on + * [NotificationChannels.CHANNEL_TEST_PUSH], and [randomId] otherwise, so other pushes each post a + * notification of their own. A chat posts under its chat id and does not come through here. + * + * @param channelId the id of the channel the notification posts on + */ +fun planNotificationId(channelId: String, randomId: () -> Int): Int = + if (channelId == NotificationChannels.CHANNEL_TEST_PUSH) TEST_PUSH_NOTIFICATION_ID else randomId() diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 69e8a70323..8214aec522 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -377,7 +377,7 @@ class NotificationService : FirebaseMessagingService(), builder.applyChatStyle(chatId, styling, groupKey, title, body, payload?.chatMetadata) } else { builder.setContentTitle(title).setContentText(body) - SecureRandom().nextInt(Int.MAX_VALUE) + planNotificationId(channel.id, randomId = { SecureRandom().nextInt(Int.MAX_VALUE) }) } notificationManager.notify(notificationId, builder.build()) diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationIdPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationIdPlannerTest.kt new file mode 100644 index 0000000000..543f76db30 --- /dev/null +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationIdPlannerTest.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.notifications + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NotificationIdPlannerTest { + + @Test + fun `test push always posts under the same id`() { + // A second test replaces the first instead of joining it in an auto-group, where Android 16 + // posts it silently. + val ids = listOf(1, 2).map { random -> + planNotificationId(NotificationChannels.CHANNEL_TEST_PUSH, randomId = { random }) + } + assertEquals(listOf(TEST_PUSH_NOTIFICATION_ID, TEST_PUSH_NOTIFICATION_ID), ids) + } + + @Test + fun `other pushes keep a random id`() { + listOf( + NotificationChannels.CHANNEL_DEFAULT, + NotificationChannels.CHANNEL_DEPOSIT_WITHDRAWAL, + NotificationChannels.CHANNEL_BUY_SELL, + NotificationChannels.CHANNEL_GAIN, + NotificationChannels.CHANNEL_CONTACT_JOIN, + ).forEach { channelId -> + assertEquals(42, planNotificationId(channelId, randomId = { 42 }), channelId) + } + } +} From 43e6d0598b08d911a72427b7c23dc54869c2c8c7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:45:30 -0400 Subject: [PATCH 2/2] fix(notification-status): title each row by its check name A row with a problem was titled with the problem sentence, so "Battery Optimization" became "Battery optimization is on" and the row changed name with its state. The title is now the check name in every state; blocked channels are titled by channel ("Chat Notifications") and the registration row stays "Registered for Push" while it checks. On a warning or fail the problem sentence leads the detail and the explanation, if any, is its second line. The report is unchanged. --- .../core/src/main/res/values/strings.xml | 35 ++--- .../internal/CheckRowTitles.kt | 72 ++++++++++ .../internal/NotificationStatusContent.kt | 126 ++++++++---------- .../internal/CheckRowTitlesTest.kt | 102 ++++++++++++++ 4 files changed, 251 insertions(+), 84 deletions(-) create mode 100644 apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitles.kt create mode 100644 apps/flipcash/features/notification-status/src/test/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitlesTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index daeba2726d..1c7b318dde 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -2025,19 +2025,25 @@ Notifications Allowed Allowed in Android settings - Notifications are turned off + Notifications are turned off Turned off for Flipcash in Android settings - Chat notifications are off + Chat Notifications + Deposit Notifications + Buy and Sell Notifications + Price Alerts + Contact Notifications + Misc. Notifications + Chat notifications are off Messages and replies won\'t show - Deposit notifications are off + Deposit notifications are off Deposits and withdrawals won\'t show - Buy and sell notifications are off + Buy and sell notifications are off Purchases and sales won\'t show - Price alerts are off + Price alerts are off Price moves won\'t show - Contact notifications are off + Contact notifications are off You won\'t hear when contacts join - Misc. notifications are off + Misc. notifications are off Other updates from Flipcash won\'t show Other Categories @@ -2051,29 +2057,28 @@ %1$s and %2$s Battery Optimization Flipcash is not optimized - Battery optimization is on + Battery optimization is on Android may delay notifications Background Activity Not restricted · standby: %1$s Android is limiting Flipcash in the background - Background activity is restricted + Background activity is restricted Notifications may not arrive Google Play Services Available Google Play services needs an update - Google Play services isn\'t available + Google Play services isn\'t available Push notifications need it Registered for Push - Checking registration… Contacting Flipcash Confirmed just now - Couldn\'t get a push token from Google - Flipcash rejected this phone\'s push token - Couldn\'t reach Flipcash + Couldn\'t get a push token from Google + Flipcash rejected this phone\'s push token + Couldn\'t reach Flipcash Check your connection Last confirmed %1$s Last confirmed just now - Registration failed + Registration failed diff --git a/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitles.kt b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitles.kt new file mode 100644 index 0000000000..91b800da57 --- /dev/null +++ b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitles.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.notificationstatus.internal + +import androidx.annotation.StringRes +import com.flipcash.app.auth.push.PushRegistrationOutcome +import com.flipcash.core.R + +/** + * A row's title: its check name, picked by [key] alone, so it reads the same pending, checking, + * passing or not. The mark and the Fix button show a problem; the title never says it. + * + * @param key a [CheckRow.key] + */ +@StringRes +fun checkTitle(key: String): Int = when (key) { + PermissionKey -> R.string.title_notificationCheckAllowed + OtherChannelsKey -> R.string.title_notificationCheckOtherCategories + BatteryKey -> R.string.title_notificationCheckBattery + BackgroundKey -> R.string.title_notificationCheckBackground + GooglePlayKey -> R.string.title_notificationCheckPlayServices + RegistrationKey -> R.string.title_notificationCheckRegistered + else -> when (StatusChannel.entries.firstOrNull { channelKey(it) == key }) { + StatusChannel.Chat -> R.string.title_notificationCheckChat + StatusChannel.DepositWithdrawal -> R.string.title_notificationCheckDeposits + StatusChannel.BuySell -> R.string.title_notificationCheckBuySell + StatusChannel.Gain -> R.string.title_notificationCheckGain + StatusChannel.ContactJoin -> R.string.title_notificationCheckContacts + StatusChannel.General -> R.string.title_notificationCheckGeneral + // evaluateNotificationStatus gives it no row: it only gates the test button. + StatusChannel.TestPush, null -> error("$key has no row") + } +} + +/** + * The sentence that leads a settled row's detail when it has a problem, "Battery optimization is + * on", with any explanation on the line after it. Null for a passing row, whose detail is one line, + * and for a registration still running, whose detail is only "Contacting Flipcash". + */ +@StringRes +fun problemLine(row: CheckRow): Int? = when (row) { + is CheckRow.Pending, is CheckRow.Rechecking, is CheckRow.OtherChannels -> null + is CheckRow.Permission -> R.string.subtitle_notificationCheckNotAllowedProblem.takeUnless { row.allowed } + is CheckRow.BlockedChannel -> when (row.channel) { + StatusChannel.Chat -> R.string.subtitle_notificationCheckChatOffProblem + StatusChannel.DepositWithdrawal -> R.string.subtitle_notificationCheckDepositsOffProblem + StatusChannel.BuySell -> R.string.subtitle_notificationCheckBuySellOffProblem + StatusChannel.Gain -> R.string.subtitle_notificationCheckGainOffProblem + StatusChannel.ContactJoin -> R.string.subtitle_notificationCheckContactsOffProblem + StatusChannel.General -> R.string.subtitle_notificationCheckGeneralOffProblem + StatusChannel.TestPush -> error("${row.channel.id} has no row") + } + is CheckRow.Battery -> R.string.subtitle_notificationCheckBatteryOnProblem.takeIf { row.optimized } + is CheckRow.Background -> when (row.status) { + CheckStatus.Fail -> R.string.subtitle_notificationCheckBackgroundRestrictedProblem + CheckStatus.Warning -> R.string.subtitle_notificationCheckBackgroundLimited + else -> null + } + is CheckRow.GooglePlay -> when (row.status) { + CheckStatus.Fail -> R.string.subtitle_notificationCheckPlayServicesMissingProblem + CheckStatus.Warning -> R.string.subtitle_notificationCheckPlayServicesUpdate + else -> null + } + is CheckRow.Registration -> when (val state = row.state) { + RegistrationState.Checking -> null + is RegistrationState.Finished -> when (state.outcome) { + PushRegistrationOutcome.Ok -> null + PushRegistrationOutcome.NoToken -> R.string.subtitle_notificationCheckNoTokenProblem + PushRegistrationOutcome.InvalidPushToken -> R.string.subtitle_notificationCheckTokenRejectedProblem + PushRegistrationOutcome.Transport -> R.string.subtitle_notificationCheckUnreachableProblem + PushRegistrationOutcome.Failed -> R.string.subtitle_notificationCheckFailedProblem + } + } +} diff --git a/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationStatusContent.kt b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationStatusContent.kt index 9715ebbf2e..458d91734a 100644 --- a/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationStatusContent.kt +++ b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationStatusContent.kt @@ -332,7 +332,7 @@ private fun Group( enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - last.row?.let { CheckRowItem(it, state, onFix) } + last.row?.let { CheckRowItem(it, onFix) } } } } @@ -340,8 +340,8 @@ private fun Group( } @Composable -private fun CheckRowItem(row: CheckRow, state: NotificationStatusViewModel.State, onFix: (Fix) -> Unit) { - val text = row.text(state) +private fun CheckRowItem(row: CheckRow, onFix: (Fix) -> Unit) { + val text = row.text() Row( modifier = Modifier .fillMaxWidth() @@ -399,91 +399,79 @@ private fun FixButton(fix: Fix, enabled: Boolean, onFix: (Fix) -> Unit) { ) } +// The title is the check name in every state. The detail leads with the problem, if there is one, +// and puts the explanation on the second line: the two lines the row reserves. @Composable -private fun CheckRow.text(state: NotificationStatusViewModel.State): Pair = when (this) { - is CheckRow.Pending -> stringResource( - when (key) { - PermissionKey -> R.string.title_notificationCheckAllowed - OtherChannelsKey -> R.string.title_notificationCheckOtherCategories - BatteryKey -> R.string.title_notificationCheckBattery - BackgroundKey -> R.string.title_notificationCheckBackground - else -> R.string.title_notificationCheckPlayServices - }, - ) to stringResource(R.string.title_notificationStatusChecking) +private fun CheckRow.text(): Pair { + val title = stringResource(checkTitle(key)) + if (this is CheckRow.Pending || this is CheckRow.Rechecking) { + return title to stringResource(R.string.title_notificationStatusChecking) + } + val problem = problemLine(this)?.let { stringResource(it) } + val detail = listOfNotNull(problem, explanation()?.takeIf { it.isNotEmpty() }).joinToString("\n") + return title to detail +} - is CheckRow.Rechecking -> previous.text(state).first to stringResource(R.string.title_notificationStatusChecking) +// The detail's line below the problem, or a passing row's only line. Null when there is none. +@Composable +private fun CheckRow.explanation(): String? = when (this) { + is CheckRow.Pending, is CheckRow.Rechecking -> null - is CheckRow.Permission -> if (allowed) { - stringResource(R.string.title_notificationCheckAllowed) to stringResource(R.string.subtitle_notificationCheckAllowed) - } else { - stringResource(R.string.title_notificationCheckNotAllowed) to stringResource(R.string.subtitle_notificationCheckNotAllowed) - } + is CheckRow.Permission -> stringResource( + if (allowed) R.string.subtitle_notificationCheckAllowed else R.string.subtitle_notificationCheckNotAllowed, + ) is CheckRow.BlockedChannel -> when (channel) { - StatusChannel.Chat -> R.string.title_notificationCheckChatOff to R.string.subtitle_notificationCheckChatOff - StatusChannel.DepositWithdrawal -> R.string.title_notificationCheckDepositsOff to R.string.subtitle_notificationCheckDepositsOff - StatusChannel.BuySell -> R.string.title_notificationCheckBuySellOff to R.string.subtitle_notificationCheckBuySellOff - StatusChannel.Gain -> R.string.title_notificationCheckGainOff to R.string.subtitle_notificationCheckGainOff - StatusChannel.ContactJoin -> R.string.title_notificationCheckContactsOff to R.string.subtitle_notificationCheckContactsOff - StatusChannel.General -> R.string.title_notificationCheckGeneralOff to R.string.subtitle_notificationCheckGeneralOff + StatusChannel.Chat -> R.string.subtitle_notificationCheckChatOff + StatusChannel.DepositWithdrawal -> R.string.subtitle_notificationCheckDepositsOff + StatusChannel.BuySell -> R.string.subtitle_notificationCheckBuySellOff + StatusChannel.Gain -> R.string.subtitle_notificationCheckGainOff + StatusChannel.ContactJoin -> R.string.subtitle_notificationCheckContactsOff + StatusChannel.General -> R.string.subtitle_notificationCheckGeneralOff // evaluateNotificationStatus gives it no row: it only gates the test button. StatusChannel.TestPush -> error("${channel.id} has no row") - }.let { (title, detail) -> stringResource(title) to stringResource(detail) } + }.let { stringResource(it) } is CheckRow.OtherChannels -> - stringResource(R.string.title_notificationCheckOtherCategories) to - stringResource(R.string.subtitle_notificationCheckOtherCategories, categoryList(covered)) + stringResource(R.string.subtitle_notificationCheckOtherCategories, categoryList(covered)) - is CheckRow.Battery -> if (optimized) { - stringResource(R.string.title_notificationCheckBatteryOn) to stringResource(R.string.subtitle_notificationCheckBatteryOn) - } else { - stringResource(R.string.title_notificationCheckBattery) to stringResource(R.string.subtitle_notificationCheckBattery) - } + is CheckRow.Battery -> stringResource( + if (optimized) R.string.subtitle_notificationCheckBatteryOn else R.string.subtitle_notificationCheckBattery, + ) is CheckRow.Background -> when (status) { - CheckStatus.Fail -> stringResource(R.string.title_notificationCheckBackgroundRestricted) to - stringResource(R.string.subtitle_notificationCheckBackgroundRestricted) - CheckStatus.Warning -> stringResource(R.string.title_notificationCheckBackground) to - stringResource(R.string.subtitle_notificationCheckBackgroundLimited) - else -> stringResource(R.string.title_notificationCheckBackground) to - stringResource(R.string.subtitle_notificationCheckBackground, bucketName(bucket)) + CheckStatus.Fail -> stringResource(R.string.subtitle_notificationCheckBackgroundRestricted) + // The problem line says it all. + CheckStatus.Warning -> null + else -> stringResource(R.string.subtitle_notificationCheckBackground, bucketName(bucket)) } is CheckRow.GooglePlay -> when (status) { - CheckStatus.Fail -> stringResource(R.string.title_notificationCheckPlayServicesMissing) to - stringResource(R.string.subtitle_notificationCheckPlayServicesMissing) - CheckStatus.Warning -> stringResource(R.string.title_notificationCheckPlayServices) to - stringResource(R.string.subtitle_notificationCheckPlayServicesUpdate) - else -> stringResource(R.string.title_notificationCheckPlayServices) to - stringResource(R.string.subtitle_notificationCheckPlayServices) + CheckStatus.Fail -> stringResource(R.string.subtitle_notificationCheckPlayServicesMissing) + CheckStatus.Warning -> null + else -> stringResource(R.string.subtitle_notificationCheckPlayServices) } is CheckRow.Registration -> when (val registration = this.state) { - RegistrationState.Checking -> stringResource(R.string.title_notificationCheckRegistering) to - stringResource(R.string.subtitle_notificationCheckRegistering) + RegistrationState.Checking -> stringResource(R.string.subtitle_notificationCheckRegistering) is RegistrationState.Finished -> when (registration.outcome) { - PushRegistrationOutcome.Ok -> - stringResource(R.string.title_notificationCheckRegistered) to - stringResource(R.string.subtitle_notificationCheckRegistered) - // A failure says what went wrong in the title; these have nothing more to explain. - PushRegistrationOutcome.NoToken -> stringResource(R.string.title_notificationCheckNoToken) to "" - PushRegistrationOutcome.InvalidPushToken -> - stringResource(R.string.title_notificationCheckTokenRejected) to "" - PushRegistrationOutcome.Transport -> - stringResource(R.string.title_notificationCheckUnreachable) to - (registration.lastConfirmedAt?.let { - val confirmedAt = it.toEpochMilliseconds() - // DateUtils says "0 minutes ago" under a minute. - if (System.currentTimeMillis() - confirmedAt < DateUtils.MINUTE_IN_MILLIS) { - stringResource(R.string.subtitle_notificationCheckLastConfirmedJustNow) - } else { - stringResource( - R.string.subtitle_notificationCheckLastConfirmed, - DateUtils.getRelativeTimeSpanString(confirmedAt).toString(), - ) - } - } ?: stringResource(R.string.subtitle_notificationCheckUnreachable)) - PushRegistrationOutcome.Failed -> stringResource(R.string.title_notificationCheckFailed) to "" + PushRegistrationOutcome.Ok -> stringResource(R.string.subtitle_notificationCheckRegistered) + // The problem line says what went wrong; these have nothing more to explain. + PushRegistrationOutcome.NoToken, + PushRegistrationOutcome.InvalidPushToken, + PushRegistrationOutcome.Failed -> null + PushRegistrationOutcome.Transport -> registration.lastConfirmedAt?.let { + val confirmedAt = it.toEpochMilliseconds() + // DateUtils says "0 minutes ago" under a minute. + if (System.currentTimeMillis() - confirmedAt < DateUtils.MINUTE_IN_MILLIS) { + stringResource(R.string.subtitle_notificationCheckLastConfirmedJustNow) + } else { + stringResource( + R.string.subtitle_notificationCheckLastConfirmed, + DateUtils.getRelativeTimeSpanString(confirmedAt).toString(), + ) + } + } ?: stringResource(R.string.subtitle_notificationCheckUnreachable) } } } diff --git a/apps/flipcash/features/notification-status/src/test/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitlesTest.kt b/apps/flipcash/features/notification-status/src/test/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitlesTest.kt new file mode 100644 index 0000000000..9729a46d17 --- /dev/null +++ b/apps/flipcash/features/notification-status/src/test/kotlin/com/flipcash/app/notificationstatus/internal/CheckRowTitlesTest.kt @@ -0,0 +1,102 @@ +package com.flipcash.app.notificationstatus.internal + +import com.flipcash.app.auth.push.PushRegistrationOutcome +import com.flipcash.core.R +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant + +class CheckRowTitlesTest { + + @Test + fun `a row keeps its check name as the title whatever its state`() { + // The title is picked by key, so pending, checking, passing and problem rows share it. + val rows = listOf( + CheckRow.Pending(BatteryKey), + CheckRow.Rechecking(CheckRow.Battery(optimized = true)), + CheckRow.Battery(optimized = false), + CheckRow.Battery(optimized = true), + ) + rows.forEach { assertEquals(R.string.title_notificationCheckBattery, checkTitle(it.key), it.toString()) } + } + + @Test + fun `each blocked channel row is titled by its channel`() { + val expected = mapOf( + StatusChannel.Chat to R.string.title_notificationCheckChat, + StatusChannel.DepositWithdrawal to R.string.title_notificationCheckDeposits, + StatusChannel.BuySell to R.string.title_notificationCheckBuySell, + StatusChannel.Gain to R.string.title_notificationCheckGain, + StatusChannel.ContactJoin to R.string.title_notificationCheckContacts, + StatusChannel.General to R.string.title_notificationCheckGeneral, + ) + assertEquals(StatusChannel.withRows.toSet(), expected.keys) + expected.forEach { (channel, title) -> + assertEquals(title, checkTitle(CheckRow.BlockedChannel(channel).key)) + } + } + + @Test + fun `registration row is Registered for Push in every outcome`() { + val states = listOf(RegistrationState.Checking) + + PushRegistrationOutcome.entries.map { RegistrationState.Finished(it) } + states.forEach { + assertEquals(R.string.title_notificationCheckRegistered, checkTitle(CheckRow.Registration(it).key)) + } + } + + @Test + fun `a registration still running has no problem line`() { + // Its detail is "Contacting Flipcash" alone. + assertNull(problemLine(CheckRow.Registration(RegistrationState.Checking))) + } + + @Test + fun `every phone row key has a title`() { + (PhoneRowKeys + RegistrationKey).forEach { checkTitle(it) } + } + + @Test + fun `problem sentence leads the detail of a warning or fail`() { + assertEquals( + R.string.subtitle_notificationCheckBatteryOnProblem, + problemLine(CheckRow.Battery(optimized = true)), + ) + assertEquals( + R.string.subtitle_notificationCheckNotAllowedProblem, + problemLine(CheckRow.Permission(allowed = false)), + ) + assertEquals( + R.string.subtitle_notificationCheckUnreachableProblem, + problemLine(CheckRow.Registration(RegistrationState.Finished(PushRegistrationOutcome.Transport))), + ) + } + + @Test + fun `every warning or fail row has a problem line and no passing row does`() { + val rows = buildList { + add(CheckRow.Permission(allowed = true)) + add(CheckRow.Permission(allowed = false)) + StatusChannel.withRows.forEach { add(CheckRow.BlockedChannel(it)) } + add(CheckRow.OtherChannels(StatusChannel.withRows)) + add(CheckRow.Battery(optimized = true)) + add(CheckRow.Battery(optimized = false)) + listOf("active", "rare").forEach { add(CheckRow.Background(restricted = false, bucket = it)) } + add(CheckRow.Background(restricted = true, bucket = "active")) + PlayServices.entries.forEach { add(CheckRow.GooglePlay(it, resolvable = true)) } + PushRegistrationOutcome.entries.forEach { + add(CheckRow.Registration(RegistrationState.Finished(it))) + add(CheckRow.Registration(RegistrationState.Finished(it, lastConfirmedAt = Instant.DISTANT_PAST))) + } + } + rows.forEach { row -> + if (row.status == CheckStatus.Pass) { + assertNull(problemLine(row), row.toString()) + } else { + assertNotNull(problemLine(row), row.toString()) + } + } + } +}