fix: make usage insights accurate — impact ranking, honest attribution, missing rules

Accuracy fixes:
- Insights are ranked by an estimated-impact score (rough hours of extra
  radio/CPU activity, ranking only, never displayed) before the cut to
  three — declaration order no longer decides which consumer gets dropped.
- The notification insight states the measurement ('held connections open
  for N relay-hours in the background') instead of an unverified 'largest
  background cost' superlative, and only fires when the service's uptime
  covers at least half the background window — a service that ran 20
  minutes can no longer be blamed for 30 hours of background connections.
- When the measured drain split shows foreground use dominating (>=3x the
  background share with enough signal), an informational note leads the
  list saying most battery went to screen-on use — so nobody flips a
  setting expecting savings it cannot deliver.

Completeness — new rules, each with a deep link:
- PoW mining time -> compose settings (default difficulty), scored as
  full-tilt CPU.
- Reconnect churn -> relay list (a flaky relay burns a handshake plus a
  radio wake-up per retry) — distinct diagnosis from 'too many relays'.
- Push-processing wakelock time / process-start churn -> notification
  settings.

Count strings converted to plurals per res/CLAUDE.md. Insight test suite
extended to 15 cases covering the attribution gate, score ordering, the
honesty note, and each new rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
This commit is contained in:
Claude
2026-07-13 20:23:16 +00:00
parent 8a98ed3d15
commit 9d176edd1c
4 changed files with 211 additions and 31 deletions
@@ -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<Insight> {
val days = s.dayCount.coerceAtLeast(1)
val insights = mutableListOf<Insight>()
val candidates = mutableListOf<Insight>()
// 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
}
@@ -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
}
+12 -2
View File
@@ -3228,9 +3228,19 @@
<string name="resource_usage_alwayson_battery">Battery drained meanwhile (whole device)</string>
<string name="resource_usage_alwayson_settings_button">Change notification settings</string>
<string name="resource_usage_insights_section">Whats using your battery</string>
<string name="resource_usage_insight_notifications">Relay connections held while the app was closed are your largest background cost (%1$s).</string>
<string name="resource_usage_insight_notifications">The always-on notification service held relay connections open for %1$s while the app was in the background.</string>
<string name="resource_usage_insight_foreground">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.</string>
<plurals name="resource_usage_insight_churn">
<item quantity="one">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.</item>
<item quantity="other">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.</item>
</plurals>
<string name="resource_usage_insight_pow">Proof-of-work mining ran the processor at full speed for %1$s. Lowering the default difficulty reduces this directly.</string>
<string name="resource_usage_insight_push">Processing incoming notifications kept the device awake for %1$s. Changing how notifications are delivered can reduce it.</string>
<string name="resource_usage_insight_media">%1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi.</string>
<string name="resource_usage_insight_relays">The app kept %1$s relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery.</string>
<plurals name="resource_usage_insight_relays">
<item quantity="one">The app kept %1$d relay connection open on average. Each open connection keeps the radio awake — fewer relays means longer battery.</item>
<item quantity="other">The app kept %1$d relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery.</item>
</plurals>
<string name="resource_usage_insight_tor">Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic.</string>
<string name="resource_usage_insight_button_media">Media settings</string>
<string name="resource_usage_insight_button_relays">Edit relays</string>
@@ -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,