diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index daeba2726..1c7b318dd 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 000000000..91b800da5 --- /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 9715ebbf2..458d91734 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 000000000..9729a46d1 --- /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()) + } + } + } +} 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 000000000..c9c28ffec --- /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 69e8a7032..8214aec52 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 000000000..543f76db3 --- /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) + } + } +}