mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+85
@@ -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() }
|
||||
}
|
||||
}
|
||||
+98
@@ -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
|
||||
|
||||
+33
-2
@@ -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,
|
||||
|
||||
+189
-3
@@ -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,
|
||||
|
||||
@@ -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">What’s 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 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.</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>
|
||||
|
||||
+158
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user