diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt index 87811e2b30..c60e095cf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt @@ -21,30 +21,47 @@ package com.vitorpamplona.amethyst.service.resourceusage /** - * Turns a week of counters into at most [MAX_INSIGHTS] actionable + * Turns a multi-day summary into at most [MAX_INSIGHTS] actionable * recommendations, each mapping to a setting the user can actually change. * The numbers alone make the user the analyst; these rules encode the * analysis (thresholds informed by the 2026-07-12 ping study) and leave the * user only the decision. * - * Rules are ordered by typical battery impact; unlike [ResourceUsageAlerts] - * (which detects "something is wrong" and interrupts), insights render - * passively on the usage screen and use much lower thresholds — "worth - * knowing", not "pathological". + * Candidates that fire are ranked by an estimated-impact [score] — a rough + * conversion of each signal to "hours of extra radio/CPU activity" — so the + * cut to [MAX_INSIGHTS] drops the smallest consumers, not whichever rule + * happened to be declared last. Scores are order-of-magnitude heuristics for + * RANKING ONLY and are never shown to the user. + * + * When the measured battery split says foreground use dominates, a non-action + * [Target.FOREGROUND_INFO] note leads the list: most drain being screen-on + * time is the honest headline, and without it users would act on a minor + * insight expecting savings the settings can't deliver. + * + * Unlike [ResourceUsageAlerts] (which detects "something is wrong" and + * interrupts), insights render passively on the usage screen and use much + * lower thresholds — "worth knowing", not "pathological". */ object UsageInsights { /** Each target names the settings surface that can act on the insight. */ enum class Target { + /** Informational only — no button. Foreground/screen use dominates the measured drain. */ + FOREGROUND_INFO, NOTIFICATION_SETTINGS, MEDIA_SETTINGS, RELAY_SETTINGS, + RELAY_CHURN, + POW_SETTINGS, + PUSH_PROCESSING, PRIVACY_SETTINGS, } data class Insight( val target: Target, - /** ms for time-based insights, bytes for data, count for relays. */ + /** ms for time-based insights, bytes for data, counts for relays/restarts, percent for FOREGROUND_INFO. */ val value: Long, + /** Estimated impact in "hours of extra radio/CPU activity" — ranking only, never displayed. */ + val score: Double, ) /** @@ -53,46 +70,95 @@ object UsageInsights { */ fun evaluate(s: UsageSummary): List { val days = s.dayCount.coerceAtLeast(1) - val insights = mutableListOf() + val candidates = mutableListOf() - // Background relay connections dominate drain while the always-on - // service is in use — the one consumer with a dedicated off switch. + // Background relay connections, attributed to the always-on service + // ONLY when its uptime actually covers most of the background window — + // a service that ran 20 minutes cannot own 30 hours of background + // connections (long background audio sessions also hold relays). val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg - if (s.alwaysOnMs > 0 && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { - insights += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs) + val bgWallMs = (days * ResourceUsageAccountant.DAY_MS - s.foregroundMs).coerceAtLeast(1L) + val serviceCoversBackground = s.alwaysOnMs * 2 >= bgWallMs + if (serviceCoversBackground && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { + candidates += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs, score = hours(s.alwaysOnMs)) } // Cellular media: images/video/previews can be limited to Wi-Fi. + // Score: scattered mobile downloads pay radio ramp+tail per burst; + // ~60 MB of feed media ≈ an hour of radio-active time. val cellularMediaBytes = (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { - insights += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes) + candidates += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes, score = cellularMediaBytes / (60.0 * 1024 * 1024)) } - // Average simultaneous relay connections across the whole period: - // every open connection is server-pinged every 30-70s, so the radio - // never sleeps while they're up. Fewer relays = less radio time. + // Average simultaneous relay connections: every open connection is + // server-pinged every 30-70s. Score: marginal — the radio is often up + // anyway; extra relays add pings, duplicates, and handshakes. val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) if (avgRelays > AVG_RELAYS) { - insights += Insight(Target.RELAY_SETTINGS, avgRelays) + candidates += Insight(Target.RELAY_SETTINGS, avgRelays, score = hours(s.relayConnMs) / 100.0) } - // In-app Tor pays circuit crypto + keep-alives for as long as it runs. + // Reconnect churn: a flaky relay in the list burns a TCP+TLS + // handshake plus a radio burst per retry. Score: ~15s of radio each. + val reconnects = s.relayConnects + s.relayConnectFails + if (reconnects > days * RECONNECTS_PER_DAY) { + candidates += Insight(Target.RELAY_CHURN, reconnects, score = reconnects * 15.0 / 3600.0) + } + + // NIP-13 mining: multi-core CPU flat out — roughly 3x the drain rate + // of an ordinary radio-active hour. + if (s.powMs > days * POW_MINUTES_PER_DAY * MS_PER_MINUTE) { + candidates += Insight(Target.POW_SETTINGS, s.powMs, score = hours(s.powMs) * 3.0) + } + + // Push processing: wakelock time plus process cold starts (each + // restart re-parses, re-connects, and pays a radio burst, ~10s each). + val pushScore = hours(s.wakelockMs) + s.appStarts * 10.0 / 3600.0 + if (s.wakelockMs > days * WAKELOCK_MINUTES_PER_DAY * MS_PER_MINUTE || s.appStarts > days * APP_STARTS_PER_DAY) { + candidates += Insight(Target.PUSH_PROCESSING, s.wakelockMs, score = pushScore) + } + + // In-app Tor: circuit crypto + keep-alives are overhead ON TOP of + // traffic that would exist anyway — count a fraction of its uptime. if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { - insights += Insight(Target.PRIVACY_SETTINGS, s.torMs) + candidates += Insight(Target.PRIVACY_SETTINGS, s.torMs, score = hours(s.torMs) * 0.3) } - return insights.take(MAX_INSIGHTS) + val ranked = candidates.sortedByDescending { it.score }.take(MAX_INSIGHTS) + + // Honesty note: when the measured split says the battery went to + // screen-on use, say so first — settings mainly reduce the background + // share, and the user deserves to know how big that share is. + val fgDominates = + s.batteryDrainFg >= MIN_DRAIN_SIGNAL_PCT && + s.batteryDrainFg >= 3 * s.batteryDrainBg.coerceAtLeast(1L) + return if (fgDominates) { + listOf(Insight(Target.FOREGROUND_INFO, s.batteryDrainFg, score = 0.0)) + ranked + } else { + ranked + } } + private fun hours(ms: Long): Double = ms / MS_PER_HOUR.toDouble() + const val MAX_INSIGHTS = 3 private const val MS_PER_HOUR = 60L * 60L * 1000L + private const val MS_PER_MINUTE = 60L * 1000L // Per-day thresholds — deliberately well below the alert levels. const val BG_RELAY_HOURS_PER_DAY = 3L const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L const val AVG_RELAYS = 25L + const val RECONNECTS_PER_DAY = 500L + const val POW_MINUTES_PER_DAY = 10L + const val WAKELOCK_MINUTES_PER_DAY = 5L + const val APP_STARTS_PER_DAY = 15L const val TOR_HOURS_PER_DAY = 4L + + /** Foreground drain below this many percent points is too noisy to call a trend. */ + const val MIN_DRAIN_SIGNAL_PCT = 5L } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 2e9f97683b..bd974db39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -253,11 +253,14 @@ private fun InsightsSection( text = insightText(insight), style = MaterialTheme.typography.bodyMedium, ) - TextButton( - onClick = { nav.nav(insightRoute(insight.target)) }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringRes(insightButton(insight.target))) + val route = insightRoute(insight.target) + if (route != null) { + TextButton( + onClick = { nav.nav(route) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(insightButton(insight.target))) + } } } } @@ -267,30 +270,50 @@ private fun InsightsSection( @Composable private fun insightText(insight: UsageInsights.Insight): String = when (insight.target) { + UsageInsights.Target.FOREGROUND_INFO -> + stringRes(R.string.resource_usage_insight_foreground, insight.value.toString()) UsageInsights.Target.NOTIFICATION_SETTINGS -> stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) UsageInsights.Target.MEDIA_SETTINGS -> stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) - UsageInsights.Target.RELAY_SETTINGS -> - stringRes(R.string.resource_usage_insight_relays, insight.value.toString()) + UsageInsights.Target.RELAY_SETTINGS -> { + val relays = insight.value.toInt() + pluralStringResource(R.plurals.resource_usage_insight_relays, relays, relays) + } + UsageInsights.Target.RELAY_CHURN -> { + val reconnects = insight.value.toInt() + pluralStringResource(R.plurals.resource_usage_insight_churn, reconnects, reconnects) + } + UsageInsights.Target.POW_SETTINGS -> + stringRes(R.string.resource_usage_insight_pow, formatDurationMs(insight.value)) + UsageInsights.Target.PUSH_PROCESSING -> + stringRes(R.string.resource_usage_insight_push, formatDurationMs(insight.value)) UsageInsights.Target.PRIVACY_SETTINGS -> stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) } -private fun insightRoute(target: UsageInsights.Target): Route = +private fun insightRoute(target: UsageInsights.Target): Route? = when (target) { + UsageInsights.Target.FOREGROUND_INFO -> null UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays + UsageInsights.Target.RELAY_CHURN -> Route.EditRelays + UsageInsights.Target.POW_SETTINGS -> Route.ComposeSettings + UsageInsights.Target.PUSH_PROCESSING -> Route.NotificationSettings UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions } @StringRes private fun insightButton(target: UsageInsights.Target): Int = when (target) { + UsageInsights.Target.FOREGROUND_INFO -> R.string.resource_usage_insights_section UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.RELAY_CHURN -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.POW_SETTINGS -> R.string.compose_settings + UsageInsights.Target.PUSH_PROCESSING -> R.string.resource_usage_alwayson_settings_button UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 53ad796d07..5067aaf011 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3228,9 +3228,19 @@ Battery drained meanwhile (whole device) Change notification settings What’s using your battery - Relay connections held while the app was closed are your largest background cost (%1$s). + The always-on notification service held relay connections open for %1$s while the app was in the background. + Most of your measured battery use (%1$s%%) happened while you were actively using the app — screen and browsing, not background activity. Settings changes mainly reduce the background share. + + Relays reconnected %1$d time. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. + Relays reconnected %1$d times. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. + + Proof-of-work mining ran the processor at full speed for %1$s. Lowering the default difficulty reduces this directly. + Processing incoming notifications kept the device awake for %1$s. Changing how notifications are delivered can reduce it. %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. - The app kept %1$s relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + + The app kept %1$d relay connection open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + The app kept %1$d relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. Media settings Edit relays diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index a4ae9b092a..e972982b3d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -676,10 +676,11 @@ class UsageInsightsTest { @Test fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { + // Service ran 100 of the ~168 background hours: it owns the connections. val s = summary( mapOf( - UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.ALWAYS_ON_MS to 100L * 3_600_000L, UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, ), days = 7, @@ -688,6 +689,86 @@ class UsageInsightsTest { assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) } + @Test + fun briefServiceUptimeIsNotBlamedForBackgroundConnectionsItCannotOwn() { + // Service ran 1h all week; 28h of background connections came from + // something else (e.g. background audio) — no notification insight. + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 1L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + ), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) + } + + @Test + fun insightsAreRankedByEstimatedImpactNotDeclarationOrder() { + // PoW at 100h scores ~300; the notification insight scores ~150 — + // PoW must come first even though its rule is declared later. + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 150L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + UsageKeys.POW_MS to 100L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.POW_SETTINGS, insights[0].target) + assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights[1].target) + } + + @Test + fun powMiningTimeSuggestsComposeSettings() { + val s = summary(mapOf(UsageKeys.POW_MS to 7L * 15L * 60_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.POW_SETTINGS }) + } + + @Test + fun reconnectChurnSuggestsReviewingTheRelayList() { + val s = summary(mapOf(UsageKeys.relayConnects(mobile = true, foreground = false) to 7L * 600L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_CHURN }) + } + + @Test + fun pushProcessingTimeSuggestsNotificationSettings() { + val s = summary(mapOf(UsageKeys.WAKELOCK_NOTIF_MS to 7L * 10L * 60_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PUSH_PROCESSING }) + } + + @Test + fun foregroundDominatedDrainLeadsWithTheHonestyNote() { + val s = + summary( + mapOf( + UsageKeys.BATTERY_DRAIN_FG to 20L, + UsageKeys.BATTERY_DRAIN_BG to 2L, + UsageKeys.TOR_MS to 7L * 5L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.FOREGROUND_INFO, insights.first().target) + assertTrue(insights.any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun balancedDrainDoesNotAddTheHonestyNote() { + val s = + summary( + mapOf( + UsageKeys.BATTERY_DRAIN_FG to 10L, + UsageKeys.BATTERY_DRAIN_BG to 8L, + ), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.FOREGROUND_INFO }) + } + @Test fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { val s = @@ -745,7 +826,7 @@ class UsageInsightsTest { val s = summary( mapOf( - UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.ALWAYS_ON_MS to 168L * 3_600_000L, UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, UsageKeys.TOR_MS to 7L * 10L * 3_600_000L,