diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index d32b4ec51f..01a5005231 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -259,6 +259,7 @@ dependencies { implementation(project(":apps:flipcash:features:menu")) implementation(project(":apps:flipcash:features:lab")) implementation(project(":apps:flipcash:features:device-logs")) + implementation(project(":apps:flipcash:features:notification-status")) implementation(project(":apps:flipcash:features:appupdates")) implementation(project(":apps:flipcash:features:deposit")) implementation(project(":apps:flipcash:features:myaccount")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt index 67474c57e4..0dd9db1fbe 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt @@ -2,6 +2,7 @@ package com.flipcash.app import android.app.Application import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.NotificationManagerCompat import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil3.ImageLoader @@ -23,6 +24,7 @@ import com.getcode.utils.trace import dev.bmcreations.phantom.connect.PhantomSdk import dagger.Lazy import com.flipcash.app.messenger.LinkPreviewStartup +import com.flipcash.app.notifications.NotificationChannels import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -85,6 +87,7 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader. super.onCreate() PhantomSdk.init(this) startupScope.launch { + NotificationChannels.createAll(this@FlipcashApp, NotificationManagerCompat.from(this@FlipcashApp)) authManager.get().init() eventRepository.get() preferredCurrencyController.get() diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 617fd298d0..c54d1ac163 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -20,6 +20,7 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy import com.flipcash.app.devicelogs.DeviceLogsScreen +import com.flipcash.app.notificationstatus.NotificationStatusScreen import com.flipcash.app.backupkey.BackupKeyScreen import com.flipcash.app.balance.WalletScreen import com.flipcash.app.cash.CashScreen @@ -225,6 +226,7 @@ internal fun appEntryProvider( annotatedEntry { TrustedWebsitesScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { DeviceLogsScreen() } + annotatedEntry { NotificationStatusScreen() } annotatedEntry { AccountSelectionScreen() } annotatedEntry { UserFlagsScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 5704c87c20..3c6d176c85 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -352,6 +352,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object DeviceLogs : Menu @Serializable + data object NotificationStatus : Menu + @Serializable data object AccountSelection : Menu @Serializable data object UserProfile : Menu diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index be9f6e2903..1f0b7743a2 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1967,4 +1967,91 @@ Select Recipient Share %1$s Recent Chats + + Notifications + On This Phone + With Flipcash + Notifications are working + Notifications are off + Checking… + + %1$d thing to check + %1$d things to check + + + %1$d thing to fix + %1$d things to fix + + Checked just now + + %1$d check passed + %1$d checks passed + + Send Test Notification + Send Again + Share Report + Turn On + Fix + Try Again + Check Again + Flipcash sends the test to every phone signed in to this account. + Turn on notifications to send a test. + Turn on %1$s in Android settings to send a test. + Test notification sent + Couldn\'t reach Flipcash + Couldn\'t send a test notification + + Notifications Allowed + Allowed in Android settings + Notifications are turned off + Turned off for Flipcash in Android settings + Chat notifications are off + Messages and replies won\'t show + Deposit notifications are off + Deposits and withdrawals won\'t show + Buy and sell notifications are off + Purchases and sales won\'t show + Price alerts are off + Price moves won\'t show + Contact notifications are off + You won\'t hear when contacts join + Misc. notifications are off + Other updates from Flipcash won\'t show + Other Categories + + %1$s are on + chat + payments + gains + contacts + general updates + + %1$s and %2$s + Battery Optimization + Flipcash is not optimized + 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 + Notifications may not arrive + Google Play Services + Available + Google Play services needs an update + 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 + Check your connection + + Last confirmed %1$s + Last confirmed just now + Registration failed diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt index 9e88254eb9..7137732177 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Palette import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -74,6 +75,15 @@ internal data object DeviceLogs : FullMenuItem() { override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.DeviceLogs) } +internal data object NotificationStatus : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Notifications) + override val name: String + @Composable get() = stringResource(CoreR.string.title_notificationStatus) + override val action: SettingsViewModel.Event = + SettingsViewModel.Event.OpenScreen(AppRoute.Menu.NotificationStatus) +} + internal data object BetaFlags : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Filled.Science) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index 1be724447e..4e031ce6e3 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -289,7 +289,7 @@ internal class SettingsViewModel @Inject constructor( SettingsSection(R.string.title_settingsSectionPrivacy, listOf(Blocklist, TrustedWebsites)), SettingsSection( R.string.title_advancedFeatures, - listOf(/* BillCustomizer, */ DeviceLogs, BetaFlags, SwitchAccount), + listOf(/* BillCustomizer, */ DeviceLogs, NotificationStatus, BetaFlags, SwitchAccount), ), SettingsSection( R.string.title_settingsSectionAccount, diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt index 16265cba76..648ddce333 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -12,6 +12,7 @@ import com.flipcash.app.myaccount.internal.settings.BetaFlags import com.flipcash.app.myaccount.internal.settings.Blocklist import com.flipcash.app.myaccount.internal.settings.DeleteAccount import com.flipcash.app.myaccount.internal.settings.DeviceLogs +import com.flipcash.app.myaccount.internal.settings.NotificationStatus import com.flipcash.app.myaccount.internal.settings.LogOut import com.flipcash.app.myaccount.internal.settings.RequireBiometrics import com.flipcash.app.myaccount.internal.settings.SettingsViewModel @@ -76,7 +77,7 @@ class SettingsViewModelTest { listOf( AccessKey, RequireBiometrics, Blocklist, TrustedWebsites, - DeviceLogs, BetaFlags, + DeviceLogs, NotificationStatus, BetaFlags, LogOut, DeleteAccount, ), state.items(), diff --git a/apps/flipcash/features/notification-status/.gitignore b/apps/flipcash/features/notification-status/.gitignore new file mode 100644 index 0000000000..9f2a078806 --- /dev/null +++ b/apps/flipcash/features/notification-status/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/apps/flipcash/features/notification-status/build.gradle.kts b/apps/flipcash/features/notification-status/build.gradle.kts new file mode 100644 index 0000000000..0ad476a117 --- /dev/null +++ b/apps/flipcash/features/notification-status/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.features.notificationstatus" +} + +dependencies { + implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:notifications")) + implementation(project(":libs:logging")) + implementation(project(":services:flipcash")) + implementation(libs.play.services.base) + implementation(libs.bundles.haze) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.grpc.stub) + testImplementation(testFixtures(project(":libs:coroutines"))) + testImplementation(project(":apps:flipcash:shared:push")) +} diff --git a/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/NotificationStatusScreen.kt b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/NotificationStatusScreen.kt new file mode 100644 index 0000000000..af1b3a5ce8 --- /dev/null +++ b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/NotificationStatusScreen.kt @@ -0,0 +1,178 @@ +package com.flipcash.app.notificationstatus + +import android.content.Context +import com.getcode.ui.components.CircularIconButton +import androidx.compose.ui.graphics.Color +import androidx.compose.material3.Icon +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.animation.core.animateFloatAsState +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.android.IntentUtils +import com.flipcash.app.notificationstatus.internal.Fix +import com.flipcash.app.notificationstatus.internal.NotificationStatusContent +import com.flipcash.app.notificationstatus.internal.NotificationStatusViewModel +import com.flipcash.app.notificationstatus.internal.NotificationStatusViewModel.Event +import com.flipcash.app.notificationstatus.internal.TestResult +import com.flipcash.core.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.TopScrollEdge +import com.getcode.ui.components.toast.LocalFloatingToastHost +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.theme.ScaffoldBarPlacement +import dev.chrisbanes.haze.rememberHazeState +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.rememberNotificationPermission +import com.google.android.gms.common.GoogleApiAvailability +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Composable +fun NotificationStatusScreen() { + val navigator = LocalCodeNavigator.current + val context = LocalContext.current + val toasts = LocalFloatingToastHost.current + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + // A grant lets the phone register, so every check runs again, Registration included. + val permission = rememberNotificationPermission { result -> + viewModel.dispatchEvent(if (result == PermissionResult.Granted) Event.PermissionGranted else Event.Refresh) + } + + // Covers coming back from system settings after a Fix. Only the phone's settings are read + // again; registration keeps its last result (Decision 2). + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.dispatchEvent(Event.Refresh) + } + + val scrollState = rememberScrollState() + val hazeState = rememberHazeState() + + // As Settings: the page scrolls under the app bar and blurs into it (TopScrollEdge). + CodeScaffold( + barPlacement = ScaffoldBarPlacement.Overlay, + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_notificationStatus), + onBackIconClicked = { navigator.pop() }, + endContent = { + CheckAgainAction( + enabled = !state.running, + onClick = { viewModel.dispatchEvent(Event.CheckAgain) }, + ) + }, + ) + }, + ) { barPadding -> + val topPadding = barPadding.calculateTopPadding() + Box(modifier = Modifier.fillMaxSize()) { + NotificationStatusContent( + state = state, + topPadding = topPadding, + scrollState = scrollState, + hazeState = hazeState, + onFix = { fix -> + when (fix) { + Fix.TurnOn -> { + // Ask while Android still allows it; after that only settings can turn it on. + val askable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + permission.status in setOf(PermissionResult.NotRequested, PermissionResult.Denied) + if (askable) permission.launch() else context.openAppNotificationSettings() + } + is Fix.ChannelSettings -> context.startActivity( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, fix.channel.id), + ) + Fix.BatterySettings -> + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + Fix.AppDetails -> context.startActivity(IntentUtils.appSettings(context)) + Fix.ResolvePlayServices -> state.device?.let { device -> + GoogleApiAvailability.getInstance() + .getErrorResolutionPendingIntent(context, device.playServicesCode, 0) + ?.send() + } + Fix.TryAgain -> viewModel.dispatchEvent(Event.TryAgain) + } + }, + onToggleExpanded = { viewModel.dispatchEvent(Event.ToggleExpanded) }, + onSendTest = { viewModel.dispatchEvent(Event.SendTest) }, + onShareReport = { viewModel.dispatchEvent(Event.ShareReportRequested) }, + ) + TopScrollEdge(hazeState = hazeState, scrollState = scrollState, height = topPadding) + } + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { context.startActivity(IntentUtils.share(it.text)) } + .launchIn(this) + } + + val testSent = stringResource(R.string.toast_notificationStatusTestSent) + val testSentIcon = ImageVector.vectorResource(R.drawable.ic_check_circle) + val testUnreachable = stringResource(R.string.toast_notificationStatusTestUnreachable) + val testFailed = stringResource(R.string.toast_notificationStatusTestFailed) + LaunchedEffect(viewModel, toasts) { + if (toasts == null) return@LaunchedEffect + viewModel.eventFlow + .filterIsInstance() + .onEach { event -> + launch { + when (event.result) { + TestResult.Sent -> toasts.show(message = testSent, icon = testSentIcon) + TestResult.Unreachable -> toasts.show(message = testUnreachable) + TestResult.Failed -> toasts.show(message = testFailed) + } + } + } + .launchIn(this) + } +} + +private fun Context.openAppNotificationSettings() { + startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName), + ) +} + +/** Re-runs every check. Dimmed and inert while a run is in progress (Behaviour 7). */ +@Composable +private fun CheckAgainAction(enabled: Boolean, onClick: () -> Unit) { + val alpha by animateFloatAsState(if (enabled) 1f else DisabledContentAlpha, label = "check again alpha") + CircularIconButton(enabled = enabled, onClick = onClick) { size -> + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.action_checkAgain), + tint = Color.White.copy(alpha = alpha), + modifier = Modifier.requiredSize(size), + ) + } +} + +// Matches ListItem's dimmed state for an action that can't run right now. +private const val DisabledContentAlpha = 0.38f diff --git a/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/DeviceSnapshotReader.kt b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/DeviceSnapshotReader.kt new file mode 100644 index 0000000000..42e49f78de --- /dev/null +++ b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/DeviceSnapshotReader.kt @@ -0,0 +1,66 @@ +package com.flipcash.app.notificationstatus.internal + +import android.Manifest +import android.app.ActivityManager +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.PowerManager +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.flipcash.app.notifications.NotificationChannels +import com.flipcash.app.notifications.currentStandbyBucket +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +/** Reads the phone's notification settings into a [DeviceSnapshot]. */ +internal class DeviceSnapshotReader @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun read(): DeviceSnapshot { + val notificationManager = context.getSystemService()!! + val playServices = GoogleApiAvailability.getInstance() + val playCode = playServices.isGooglePlayServicesAvailable(context) + + return DeviceSnapshot( + notificationsAllowed = notificationsAllowed(notificationManager), + channels = channels(notificationManager), + ignoringBatteryOptimizations = context.getSystemService() + ?.isIgnoringBatteryOptimizations(context.packageName) == true, + backgroundRestricted = context.getSystemService()?.isBackgroundRestricted == true, + standbyBucket = context.currentStandbyBucket(), + playServices = when (playCode) { + ConnectionResult.SUCCESS -> PlayServices.Available + ConnectionResult.SERVICE_UPDATING, + ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED -> PlayServices.NeedsUpdate + else -> PlayServices.Unavailable + }, + playServicesCode = playCode, + playServicesResolvable = playServices.isUserResolvableError(playCode), + ) + } + + private fun notificationsAllowed(manager: NotificationManager): Boolean { + val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + return granted && manager.areNotificationsEnabled() + } + + private fun channels(manager: NotificationManager): List { + // Channels are created at app start; this covers a check that runs first. + NotificationChannels.createAll(context, NotificationManagerCompat.from(context)) + return StatusChannel.entries.mapNotNull { status -> + val channel = manager.getNotificationChannel(status.id) ?: return@mapNotNull null + ChannelSetting( + channel = status, + importance = channel.importance, + groupBlocked = channel.group?.let { manager.getNotificationChannelGroup(it)?.isBlocked } == true, + ) + } + } +} diff --git a/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationReport.kt b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationReport.kt new file mode 100644 index 0000000000..4393aaafed --- /dev/null +++ b/apps/flipcash/features/notification-status/src/main/kotlin/com/flipcash/app/notificationstatus/internal/NotificationReport.kt @@ -0,0 +1,84 @@ +package com.flipcash.app.notificationstatus.internal + +import com.flipcash.app.auth.push.PushRegistration +import com.flipcash.app.auth.push.PushRegistrationResult +import kotlin.time.Instant + +data class ReportHeader( + /** e.g. "2026-10-28 09:41 (UTC-4)" */ + val generatedAt: String, + val appVersion: String, + val osVersion: String, + val device: String, +) + +/** + * The plain-text report support asks for. Every check line carries its raw value in brackets so it + * reads without a screenshot. It never includes the full token, the installation ID, the account ID + * or any user names. + */ +fun buildNotificationReport( + header: ReportHeader, + device: DeviceSnapshot, + registration: RegistrationState, + /** The store's newest record, from any call site. */ + lastRecord: PushRegistration?, + /** The token this page's latest run sent; null when it sent none, and so saved nothing. */ + tokenSuffix: String?, + formatTime: (Instant) -> String, +): String = buildString { + appendLine("Flipcash notification report") + appendLine(header.generatedAt) + appendLine("App ${header.appVersion} · ${header.osVersion} · ${header.device}") + appendLine() + + val status = evaluateNotificationStatus(device, registration) + fun CheckStatus.label() = name.lowercase() + + appendLine("Notifications Allowed: ${CheckRow.Permission(device.notificationsAllowed).status.label()} (enabled ${device.notificationsAllowed})") + // Every channel, whether or not the page shows a row for it. + StatusChannel.entries.forEach { channel -> + val setting = device.channels.firstOrNull { it.channel == channel } + // As on the page: only a blocked channel is a problem. + val channelStatus = if (setting?.isBlocked == true) channel.whenBlocked else CheckStatus.Pass + val raw = setting?.let { "importance ${it.importance}, group blocked ${it.groupBlocked}" } ?: "missing" + appendLine("Channel ${channel.id}: ${channelStatus.label()} ($raw)") + } + status.rows.forEach { row -> + when (row) { + is CheckRow.Battery -> + appendLine("Battery Optimization: ${row.status.label()} (ignoring ${device.ignoringBatteryOptimizations})") + is CheckRow.Background -> + appendLine("Background Activity: ${row.status.label()} (restricted ${row.restricted}, bucket ${row.bucket})") + is CheckRow.GooglePlay -> + appendLine("Google Play Services: ${row.status.label()} (code ${device.playServicesCode})") + is CheckRow.Registration -> { + // This page's latest run, never an older saved record. + val line = when (val state = row.state) { + RegistrationState.Checking -> row.status.label() + is RegistrationState.Finished -> { + // A run saves a record exactly when it sent a token. + val detail = lastRecord?.takeIf { tokenSuffix != null }?.let { record -> + // ", ,