diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 9c7e641739..eecf3161a1 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -106,10 +106,16 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: at flush from BatteryManager: NOT app-isolated, but the ground truth that report corpora can correlate the other counters against -Deliberately not tracked (v1): per-screen time (route names leak behavior -patterns into a report — needs its own privacy review), per-coroutine or -per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU -matters at all first), signing (user-action-rate, negligible). +- `screen..ms` — foreground time per screen, added after the original + privacy review: only the route's base NAME is recorded (screenNameOf strips + every navigation argument before the value leaves the nav layer), so the + ledger can say "Profile" but never whose profile + +Deliberately not tracked (v1): per-coroutine or per-dispatcher CPU (needs a +thread registry; `cpu.ms` answers whether CPU matters at all first). +(Two earlier v1 exclusions were later revisited: per-screen time ships with +names-only privacy as above, and signing is now counted per signer kind +because NIP-46/NIP-55 signatures are network/IPC round-trips, not local CPU.) Flat keys keep the store schema-free: new counters need no migration. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 04f711baba..d8cba9a64b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeInteg import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys @@ -345,6 +346,15 @@ class AppModules( private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + // Time-per-screen (route base names only — arguments never reach the + // ledger). Fed by the navigation listener in AppNavigation; foreground + // gating means backgrounding on a screen closes its segment. + val screenTime = ScreenTimeIntegrator(resourceUsage) + + init { + screenTime.start(applicationIOScope, foregroundTracker.isForeground) + } + // In-app (Arti) Tor uptime. Watches the raw TorService status — NOT // TorManager.status, whose upstream is WhileSubscribed and calls // service.start() when collected, so a permanent ledger subscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index cd23077473..e26eb98678 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -118,6 +118,14 @@ class ResourceUsageReportAssembler { if (subsystems.isNotEmpty()) { append("| By subsystem | $subsystems |\n") } + val screens = + s.screenTimeMs.entries + .sortedByDescending { it.value } + .take(8) + .joinToString(", ") { "${it.key} ${formatDurationMs(it.value)}" } + if (screens.isNotEmpty()) { + append("| Screen time | $screens |\n") + } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt new file mode 100644 index 0000000000..b995054ace --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.resourceusage + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Integrates time-per-screen into the ledger (`screen..ms`): which + * parts of the app the display/CPU time actually goes to, so a report can + * distinguish "8 h of video" from "8 h of feeds". + * + * PRIVACY: only the route's base name is recorded ([screenNameOf] strips + * navigation arguments before anything reaches the ledger) — "Profile" is + * tracked, whose profile never is. Time only accrues while the app is in the + * foreground: the current-route × foreground combination is the segment + * state, so backgrounding on a screen closes its segment. + */ +class ScreenTimeIntegrator( + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + private val currentScreen = MutableStateFlow(null) + + /** Called from the navigation listener with an already-sanitized name (or null when unknown). */ + fun onScreen(name: String?) { + currentScreen.value = name + } + + fun start( + scope: CoroutineScope, + isForeground: Flow, + ): Job { + registerFlushHook() + return scope.launch { + combine(currentScreen, isForeground) { screen, fg -> if (fg) screen else null } + .collect { transitionTo(it) } + } + } + + override fun account( + state: String, + elapsedMs: Long, + ) { + if (elapsedMs > 0) accountant.add(UsageKeys.screenMs(state), elapsedMs) + } + + companion object { + /** + * Reduces a Navigation Compose route pattern to its bare screen name: + * `com...routes.Route.Profile/{userId}?tab={tab}` becomes `Profile`. + * Everything after `/` or `?` — where the arguments live — is dropped + * BEFORE the value leaves the navigation layer. + */ + fun screenNameOf(route: String?): String? = + route + ?.substringBefore('/') + ?.substringBefore('?') + ?.substringAfterLast('.') + ?.takeIf { it.isNotBlank() } + } +} 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 new file mode 100644 index 0000000000..87811e2b30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.resourceusage + +/** + * Turns a week of counters 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". + */ +object UsageInsights { + /** Each target names the settings surface that can act on the insight. */ + enum class Target { + NOTIFICATION_SETTINGS, + MEDIA_SETTINGS, + RELAY_SETTINGS, + PRIVACY_SETTINGS, + } + + data class Insight( + val target: Target, + /** ms for time-based insights, bytes for data, count for relays. */ + val value: Long, + ) + + /** + * Evaluates a multi-day summary ([UsageSummary.dayCount] normalizes the + * thresholds, so a 2-day-old install is judged on 2 days, not 7). + */ + fun evaluate(s: UsageSummary): List { + val days = s.dayCount.coerceAtLeast(1) + val insights = mutableListOf() + + // Background relay connections dominate drain while the always-on + // service is in use — the one consumer with a dedicated off switch. + 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) + } + + // Cellular media: images/video/previews can be limited to Wi-Fi. + 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) + } + + // 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. + val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) + if (avgRelays > AVG_RELAYS) { + insights += Insight(Target.RELAY_SETTINGS, avgRelays) + } + + // In-app Tor pays circuit crypto + keep-alives for as long as it runs. + if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { + insights += Insight(Target.PRIVACY_SETTINGS, s.torMs) + } + + return insights.take(MAX_INSIGHTS) + } + + const val MAX_INSIGHTS = 3 + private const val MS_PER_HOUR = 60L * 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 TOR_HOURS_PER_DAY = 4L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 357dfd644d..e43cde73a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -152,6 +152,16 @@ object UsageKeys { /** Time spent actively listening for GPS/location updates (geohash tagging). */ const val LOCATION_MS = "location.ms" + /** + * `screen.Home.ms` — time a screen was visible while the app was in the + * foreground. PRIVACY: only the route's base NAME is ever recorded, never + * its navigation arguments — "Profile" is tracked, whose profile is not + * (see ScreenTimeIntegrator.screenNameOf, which strips them). + */ + fun screenMs(screen: String): String = "$SCREEN_PREFIX$screen.ms" + + const val SCREEN_PREFIX = "screen." + /** * NIP-04/44 decryptions and encryptions through account signers. Durations * are only metered for local-key signers (CPU cost); external/remote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index f28f851efa..1fd24f085d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -52,6 +52,7 @@ data class UsageSummary( val mediaPlayMs: Long, val powMs: Long, val torMs: Long, + val torStarts: Long, val alwaysOnMs: Long, val alwaysOnStarts: Long, val callMs: Long, @@ -65,25 +66,51 @@ data class UsageSummary( val batteryDrainBg: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, + /** cellular-only rx+tx bytes per subsystem — the scarce resource. */ + val mobileBytesPerSubsystem: Map, + /** foreground time per screen NAME (arguments are never recorded). */ + val screenTimeMs: Map, + /** how many day buckets this summary was built from (>= 1). */ + val dayCount: Int, ) { val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg companion object { - fun from(counters: Map): UsageSummary { + fun from( + counters: Map, + dayCount: Int = 1, + ): UsageSummary { fun traffic( net: String, vis: String, ) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX) val subsystems = mutableMapOf() + val mobileSubsystems = mutableMapOf() for (role in UsageKeys.HTTP_ROLES) { val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX) if (bytes > 0) subsystems[role] = bytes + val mobileBytes = + counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.RX) + + counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.TX) + if (mobileBytes > 0) mobileSubsystems[role] = mobileBytes } val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX) if (relayBytes > 0) subsystems["relay"] = relayBytes + val mobileRelayBytes = + counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.RX) + + counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.TX) + if (mobileRelayBytes > 0) mobileSubsystems["relay"] = mobileRelayBytes + + val screens = mutableMapOf() + for ((key, value) in counters) { + if (key.startsWith(UsageKeys.SCREEN_PREFIX) && key.endsWith(".ms") && value > 0) { + val name = key.removePrefix(UsageKeys.SCREEN_PREFIX).removeSuffix(".ms") + if (name.isNotBlank()) screens[name] = (screens[name] ?: 0L) + value + } + } return UsageSummary( mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG), @@ -110,6 +137,7 @@ data class UsageSummary( mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, powMs = counters[UsageKeys.POW_MS] ?: 0L, torMs = counters[UsageKeys.TOR_MS] ?: 0L, + torStarts = counters[UsageKeys.TOR_STARTS] ?: 0L, alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L, callMs = counters[UsageKeys.CALL_MS] ?: 0L, @@ -122,6 +150,9 @@ data class UsageSummary( batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L, batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L, bytesPerSubsystem = subsystems, + mobileBytesPerSubsystem = mobileSubsystems, + screenTimeMs = screens, + dayCount = dayCount.coerceAtLeast(1), ) } @@ -129,7 +160,7 @@ data class UsageSummary( fun fromDays(days: Collection>): UsageSummary { val merged = mutableMapOf() days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } } - return from(merged) + return from(merged, dayCount = days.size) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index e5e7c77fa2..d4866a71b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -42,13 +42,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.content.IntentCompat import androidx.core.util.Consumer import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert +import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen @@ -303,6 +306,7 @@ fun AppNavigation( } } + TrackScreenTime(nav) NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) @@ -327,6 +331,27 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) { } } +/** + * Feeds the resource-usage ledger with time-per-screen. Only the route's + * base name crosses this boundary — [ScreenTimeIntegrator.screenNameOf] + * strips every navigation argument first, so the ledger can say "Profile" + * but never which profile. + */ +@Composable +private fun TrackScreenTime(nav: Nav) { + DisposableEffect(nav.controller) { + val listener = + NavController.OnDestinationChangedListener { _, destination, _ -> + Amethyst.instance.screenTime.onScreen(ScreenTimeIntegrator.screenNameOf(destination.route)) + } + nav.controller.addOnDestinationChangedListener(listener) + onDispose { + nav.controller.removeOnDestinationChangedListener(listener) + Amethyst.instance.screenTime.onScreen(null) + } + } +} + @Composable fun BuildNavigation( accountViewModel: AccountViewModel, 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 2b972a6d14..2e9f97683b 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 @@ -61,10 +61,12 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs +import com.vitorpamplona.amethyst.service.resourceusage.UsageInsights import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -75,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import java.util.Locale /** * The resource-usage ledger: how much network, relay connection time, and @@ -136,14 +139,18 @@ fun ResourceUsageScreen( val weekSummary = remember(loaded) { UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) } TodayTiles(todaySummary) + WeekRatesTiles(weekSummary) + InsightsSection(weekSummary, nav) if (weekSummary.totalBytes > 0) { SettingsSection(R.string.resource_usage_trend_section) { UsageTrendChart(loaded, today) } } SubsystemSection(weekSummary) + ScreenTimeSection(weekSummary) ActivitySection(weekSummary) AlwaysOnServiceSection(weekSummary, nav) + TorServiceSection(weekSummary, nav) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -196,9 +203,120 @@ private fun StatTile( } } -/** Ranked per-feature traffic with proportion bars (7 days). */ +/** + * 7-day rates: totals aren't judgeable, rates are. Battery %/hour of use is + * the most tangible battery number we can show; average simultaneous relay + * connections is the number a user can act on by trimming their relay list. + */ +@Composable +private fun WeekRatesTiles(s: UsageSummary) { + val fgHours = s.foregroundMs / 3_600_000.0 + val tiles = + buildList { + if (fgHours >= 0.5 && s.batteryDrainFg > 0) { + add(R.string.resource_usage_tile_battery_rate to String.format(Locale.US, "%.1f%%", s.batteryDrainFg / fgHours)) + } + if (fgHours >= 0.5) { + add(R.string.resource_usage_tile_data_rate to formatBytes(((s.mobileBytesFg + s.wifiBytesFg) / fgHours).toLong())) + } + val avgRelays = s.relayConnMs.toDouble() / (s.dayCount * ResourceUsageAccountant.DAY_MS) + if (avgRelays >= 0.5) { + add(R.string.resource_usage_tile_avg_relays to String.format(Locale.US, "%.1f", avgRelays)) + } + } + if (tiles.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + tiles.forEach { (label, value) -> StatTile(label, value, Modifier.weight(1f)) } + } +} + +/** + * Up to three plain-language recommendations, each deep-linking to the + * setting that acts on it — the rules live in [UsageInsights] so they are + * unit-testable and shared with nothing UI-bound. + */ +@Composable +private fun InsightsSection( + week: UsageSummary, + nav: INav, +) { + val insights = remember(week) { UsageInsights.evaluate(week) } + if (insights.isEmpty()) return + SettingsSection(R.string.resource_usage_insights_section) { + insights.forEachIndexed { index, insight -> + if (index > 0) SettingsDivider() + Column( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = insightText(insight), + style = MaterialTheme.typography.bodyMedium, + ) + TextButton( + onClick = { nav.nav(insightRoute(insight.target)) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(insightButton(insight.target))) + } + } + } + } +} + +@Composable +private fun insightText(insight: UsageInsights.Insight): String = + when (insight.target) { + 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.PRIVACY_SETTINGS -> + stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) + } + +private fun insightRoute(target: UsageInsights.Target): Route = + when (target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings + UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings + UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays + UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions + } + +@StringRes +private fun insightButton(target: UsageInsights.Target): Int = + when (target) { + 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.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy + } + +/** + * Ranked per-feature traffic with proportion bars (7 days). Cellular is the + * scarce resource (battery and often money), so when any cellular traffic + * exists the ranking, bars, and headline value are cellular — with the total + * as secondary context. Wi-Fi-only devices fall back to totals. + */ @Composable private fun SubsystemSection(week: UsageSummary) { + val cellular = week.mobileBytesPerSubsystem + if (cellular.isNotEmpty()) { + val rows = cellular.entries.sortedByDescending { it.value } + val max = rows.first().value.coerceAtLeast(1L) + SettingsSection(R.string.resource_usage_by_subsystem_cellular) { + rows.forEach { (subsystem, bytes) -> + BarRow( + label = subsystemLabel(subsystem), + value = stringRes(R.string.resource_usage_cell_of_total, formatBytes(bytes), formatBytes(week.bytesPerSubsystem[subsystem] ?: bytes)), + fraction = bytes.toFloat() / max.toFloat(), + ) + } + } + return + } if (week.bytesPerSubsystem.isEmpty()) return val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } val max = rows.first().value.coerceAtLeast(1L) @@ -213,6 +331,29 @@ private fun SubsystemSection(week: UsageSummary) { } } +/** + * Where the screen-on time went (7 days). Route base names only — the + * ledger never records which profile/hashtag/thread a screen showed. + */ +@Composable +private fun ScreenTimeSection(week: UsageSummary) { + if (week.screenTimeMs.isEmpty()) return + val rows = + week.screenTimeMs.entries + .sortedByDescending { it.value } + .take(6) + val max = rows.first().value.coerceAtLeast(1L) + SettingsSection(R.string.resource_usage_screen_time_section) { + rows.forEach { (name, ms) -> + BarRow( + label = name, + value = formatDurationMs(ms), + fraction = ms.toFloat() / max.toFloat(), + ) + } + } +} + @StringRes private fun subsystemLabel(subsystem: String): Int = when (subsystem) { @@ -227,13 +368,21 @@ private fun subsystemLabel(subsystem: String): Int = else -> R.string.resource_usage_subsystem_other } -/** Label + value + a thin rounded proportion bar underneath. */ @Composable private fun BarRow( @StringRes label: Int, value: String, fraction: Float, color: Color = MaterialTheme.colorScheme.primary, +) = BarRow(stringRes(label), value, fraction, color) + +/** Label + value + a thin rounded proportion bar underneath. */ +@Composable +private fun BarRow( + label: String, + value: String, + fraction: Float, + color: Color = MaterialTheme.colorScheme.primary, ) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), @@ -241,7 +390,7 @@ private fun BarRow( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringRes(label), + text = label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -449,6 +598,43 @@ private fun AlwaysOnServiceSection( } } +/** + * Cost card for in-app Tor — like the always-on card: what it cost this + * week and the settings surface that controls it. + */ +@Composable +private fun TorServiceSection( + s: UsageSummary, + nav: INav, +) { + if (s.torMs <= 0) return + val starts = s.torStarts.toInt() + SettingsSection(R.string.resource_usage_tor_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_tor_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + MetricRow( + R.string.resource_usage_alwayson_uptime, + "${formatDurationMs(s.torMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}", + ) + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + TextButton( + onClick = { nav.nav(Route.PrivacyOptions) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_insight_button_privacy)) + } + } + } +} + @Composable private fun SendReportSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index df93f3236c..53ad796d07 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3227,6 +3227,22 @@ Relay connections held while closed 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). + %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. + Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. + Media settings + Edit relays + Privacy options + Battery / hour in app + Data / hour in app + Avg. relay connections + Cellular data by feature (7 days) + %1$s · %2$s total + Time by screen (7 days) + Built-in Tor + Routes the app’s traffic through the Tor network for privacy. While running it spends extra battery on encryption and keep-alive traffic, and every start pays a bootstrap. If your threat model allows, Tor use can be adjusted or turned off. Data by feature (7 days) Relay sync Images 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 6a2e216392..a4ae9b092a 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 @@ -617,6 +617,164 @@ class MeteringNostrSignerTest { } } +class ScreenTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun routeNamesLoseTheirArgumentsBeforeAnythingIsRecorded() { + assertEquals("Profile", ScreenTimeIntegrator.screenNameOf("com.vitorpamplona.amethyst.ui.navigation.routes.Route.Profile/{userId}")) + assertEquals("Hashtag", ScreenTimeIntegrator.screenNameOf("routes.Route.Hashtag/{tag}?extra={extra}")) + assertEquals("Home", ScreenTimeIntegrator.screenNameOf("routes.Route.Home")) + assertNull(ScreenTimeIntegrator.screenNameOf(null)) + assertNull(ScreenTimeIntegrator.screenNameOf("")) + } + + @Test + fun accountsScreenTimeOnlyWhileForeground() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val isForeground = MutableStateFlow(true) + val integrator = ScreenTimeIntegrator(accountant, nowMs = { clock }) + integrator.start(backgroundScope, isForeground) + testScheduler.runCurrent() + + integrator.onScreen("Home") + testScheduler.runCurrent() + clock += 5_000 + integrator.onScreen("Video") + testScheduler.runCurrent() + clock += 3_000 + isForeground.value = false // backgrounded on Video: segment closes + testScheduler.runCurrent() + clock += 60_000 // background time must not count + isForeground.value = true + testScheduler.runCurrent() + clock += 2_000 + integrator.onScreen(null) + testScheduler.runCurrent() + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(5_000L, today[UsageKeys.screenMs("Home")]) + assertEquals(5_000L, today[UsageKeys.screenMs("Video")]) + } +} + +class UsageInsightsTest { + private fun summary( + counters: Map, + days: Int, + ) = UsageSummary.fromDays(List(days) { if (it == 0) counters else emptyMap() }) + + @Test + fun quietWeekYieldsNoInsights() { + val s = summary(mapOf(UsageKeys.APP_FG_MS to 3_600_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).isEmpty()) + } + + @Test + fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) + } + + @Test + fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { + val s = + summary( + mapOf(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 cellularMediaSuggestsMediaSettingsButWifiDoesNot() { + val cellular = + summary( + mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), + days = 7, + ) + assertEquals(UsageInsights.Target.MEDIA_SETTINGS, UsageInsights.evaluate(cellular).first().target) + + val wifi = + summary( + mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(wifi).isEmpty()) + } + + @Test + fun manySimultaneousRelaysSuggestsEditingTheRelayList() { + // 40 relays open around the clock for a week. + val s = + summary( + mapOf(UsageKeys.relayConnMs(mobile = false, foreground = true) to 40L * 7L * 24L * 3_600_000L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_SETTINGS }) + } + + @Test + fun longTorUptimeSuggestsPrivacyOptions() { + val s = summary(mapOf(UsageKeys.TOR_MS to 7L * 5L * 3_600_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun thresholdsScaleWithTheNumberOfObservedDays() { + // 5 tor-hours looks fine over a week but heavy over a single day. + val counters = mapOf(UsageKeys.TOR_MS to 5L * 3_600_000L) + assertTrue(UsageInsights.evaluate(summary(counters, days = 7)).isEmpty()) + assertTrue(UsageInsights.evaluate(summary(counters, days = 1)).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun neverMoreThanThreeInsights() { + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 24L * 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, + ), + days = 7, + ) + assertEquals(UsageInsights.MAX_INSIGHTS, UsageInsights.evaluate(s).size) + } +} + +class UsageSummaryMapsTest { + @Test + fun screenTimeAndCellularMapsAreExtractedFromCounters() { + val s = + UsageSummary.from( + mapOf( + UsageKeys.screenMs("Home") to 10_000L, + UsageKeys.screenMs("Video") to 5_000L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 100L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 900L, + ), + ) + assertEquals(10_000L, s.screenTimeMs["Home"]) + assertEquals(5_000L, s.screenTimeMs["Video"]) + assertEquals(100L, s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE]) + assertEquals(1_000L, s.bytesPerSubsystem[UsageKeys.ROLE_IMAGE]) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters)