feat: actionable usage insights, rates, cellular-first bars, Tor card, and per-screen time

The screen presented evidence and left the user to be the analyst; now it
draws the conclusions and links each one to the setting that acts on it:

- Insights: a UsageInsights rules layer (unit-tested, thresholds informed by
  the ping study and normalized per observed day) renders up to three
  plain-language recommendations, each deep-linking to notification
  settings, media settings, the relay list, or privacy options.
- Rates: battery %/hour in app (from the measured drain sampler), data/hour
  in app, and average simultaneous relay connections — totals aren't
  judgeable, rates are, and avg relays is directly actionable.
- Subsystem bars rank by CELLULAR bytes when any exist (with totals as
  secondary context) — Wi-Fi bytes are nearly free, so ranking by totals
  pointed users at the wrong feature.
- Built-in Tor gets the same cost card as the always-on service: uptime,
  bootstraps, and a link to privacy options.
- Per-screen time (screen.<Name>.ms): a ScreenTimeIntegrator fed by the nav
  controller's destination listener, gated on foreground. PRIVACY: only the
  route's base name is recorded — screenNameOf strips every navigation
  argument before the value leaves the navigation layer, so the ledger says
  'Profile', never whose profile. Shown as proportion bars and included in
  the report's summary table.

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 18:07:59 +00:00
parent 53e8f8df09
commit 8a98ed3d15
11 changed files with 642 additions and 9 deletions
@@ -106,10 +106,16 @@ Flat `Map<String, Long>` 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.<Name>.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.
@@ -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
@@ -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 {
@@ -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.<Name>.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<String>(accountant, nowMs) {
private val currentScreen = MutableStateFlow<String?>(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<Boolean>,
): 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() }
}
}
@@ -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<Insight> {
val days = s.dayCount.coerceAtLeast(1)
val insights = mutableListOf<Insight>()
// 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
}
@@ -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
@@ -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<String, Long>,
/** cellular-only rx+tx bytes per subsystem — the scarce resource. */
val mobileBytesPerSubsystem: Map<String, Long>,
/** foreground time per screen NAME (arguments are never recorded). */
val screenTimeMs: Map<String, Long>,
/** 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<String, Long>): UsageSummary {
fun from(
counters: Map<String, Long>,
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<String, Long>()
val mobileSubsystems = mutableMapOf<String, Long>()
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<String, Long>()
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<Map<String, Long>>): UsageSummary {
val merged = mutableMapOf<String, Long>()
days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } }
return from(merged)
return from(merged, dayCount = days.size)
}
}
}
@@ -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,
@@ -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,
+16
View File
@@ -3227,6 +3227,22 @@
<string name="resource_usage_alwayson_bg_relay">Relay connections held while closed</string>
<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_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>
<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>
<string name="resource_usage_insight_button_privacy">Privacy options</string>
<string name="resource_usage_tile_battery_rate">Battery / hour in app</string>
<string name="resource_usage_tile_data_rate">Data / hour in app</string>
<string name="resource_usage_tile_avg_relays">Avg. relay connections</string>
<string name="resource_usage_by_subsystem_cellular">Cellular data by feature (7 days)</string>
<string name="resource_usage_cell_of_total">%1$s · %2$s total</string>
<string name="resource_usage_screen_time_section">Time by screen (7 days)</string>
<string name="resource_usage_tor_section">Built-in Tor</string>
<string name="resource_usage_tor_explanation">Routes the apps 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.</string>
<string name="resource_usage_by_subsystem">Data by feature (7 days)</string>
<string name="resource_usage_subsystem_relay">Relay sync</string>
<string name="resource_usage_subsystem_image">Images</string>
@@ -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<String, Long>,
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<String, Long>) = mapOf(*counters)