diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md new file mode 100644 index 0000000000..7d460b68bf --- /dev/null +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -0,0 +1,120 @@ +# Resource Usage Ledger — battery/data accounting, user-visible + NIP-17 reportable + +**Date:** 2026-07-12 +**Goal:** Let users (and developers) see how much network, connection time, and +background activity the app consumes, per subsystem — and let a user send that +data to the developers over NIP-17, reusing the crash-report consent pattern. +When consumption crosses "something is wrong" thresholds, proactively ask the +user (rate-limited, opt-out-able) whether they'd like to send a report. + +Background: the 2026-07-12 ping-interval study (see +`2026-07-12-relay-ping-interval-study.md`) showed the dominant energy proxy is +connection-time (relays server-ping every 30–70s while connected) and that +battery bugs are production-only phenomena — so the ledger ships in release, +collects passively, and never transmits anything without an explicit user +action. + +## Survey (existing components reused) + +- **Send path** — the crash-report pipeline: `DisplayCrashMessages` prefills + the NIP-17 DM composer via `routeToMessage(user = , draftMessage, + expiresDays = 30)`; the user taps Send; `Account.sendNip17PrivateMessage` + gift-wraps to the recipient's kind-10050 DM relays. Reused as-is — the + ledger only builds a different draft string. +- **Persistence idiom** — `ScheduledPostStore` (Jackson + Mutex + tmp-rename + + version envelope + StateFlow). Cloned as `ResourceUsageStore`. +- **Relay traffic** — counted by a new `RelayConnectionListener` + (same hook `RelayStats` uses), NOT by modifying quartz. +- **Connection time** — integrated from `INostrClient.connectedRelaysFlow()` + (exact between emissions; no timers). +- **Network class** — `ConnectivityManager.isMobileOrFalse` StateFlow. +- **Foreground** — new tiny `ForegroundTracker` (ActivityLifecycleCallbacks → + StateFlow), registered next to `AppForegroundRecycleHook`; + `MainActivity.isResumed` is not observable and slightly stricter than + process-foreground. +- **HTTP subsystems** — `RoleBasedHttpClientBuilder` already funnels every + role (image/video/uploads/money/nip05/preview/push) through two shared + clients; a cached per-role `newBuilder().addInterceptor(counting)` wrapper + gives per-subsystem byte attribution without touching the shared clients. +- **UI idioms** — `NotificationSettingsScreen` structure (`Scaffold` + + `TopBarWithBackButton` + `SettingsSection` cards), route in `Routes.kt`, + `composableFromEnd` registration, catalog entry via + `SettingsCatalogBuilder.symEntry` (icon: existing `MaterialSymbols.Bolt` — + no font regen). +- **App-open dialog** — `DisplayCrashMessages` pattern, mounted in the same + `AppNavigation` block. + +## Design + +### Counters +Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: +`....[.]`, e.g.: + +- `net.image.mobile.bg.rx` — bytes downloaded by the image subsystem on + cellular while backgrounded (same for video/uploads/money/nip05/preview/push) +- `relay.msg.wifi.fg.rx|tx` — approx relay websocket payload bytes +- `relay.connms.mobile.bg` — relay-connection-milliseconds (Σ relays × time) +- `wakelock.notif.ms` / `wakelock.notif.count` +- `worker.scheduledPost.runs` / `worker.calendarReminder.runs` / + `worker.notificationCatchUp.runs` +- `app.starts` — process starts (detects WorkManager cold-start churn) + +Flat keys keep the store schema-free: new counters need no migration. + +### Components (`amethyst/.../service/resourceusage/`) +- `UsageKeys` — key constants/builders + dimension helpers. +- `ResourceUsageStore` — daily buckets on disk (`resource_usage.json`), + `mergeInto(day, deltas)`, `allDays()`, prune, plus alert state + (lastAlertAtSec, optOut). +- `ResourceUsageAccountant` — in-memory `ConcurrentHashMap` + hot path (`add()` is called per relay frame), debounced flush (30s) into the + store, day-rollover handling, merged read API for UI/report. +- `ForegroundTracker` — startedActivities>0 as StateFlow. +- `RelayUsageListener` — `RelayConnectionListener` counting sent/received + frame sizes with current network/visibility dims. +- `RelayConnectionTimeIntegrator` — combines connectedRelays × isMobile × + isForeground; closes an accounting segment on every change and on + `closeOpenSegment()` (called from accountant flush and reads, so multi-hour + stable background sessions still account without any timer). +- `UsageCountingInterceptor` + counting response body — per-role HTTP bytes; + wrapped clients cached per (role, base client identity). +- `ResourceUsageReportAssembler` — Markdown: device/app header (crash-report + style), human summary (today + 7 days), fenced per-day counter dump. +- `ResourceUsageAlerts` — pure threshold logic (see below) + rate limiting. +- `DisplayResourceUsageAlert` — consent dialog (view details / send / not + now / don't ask again). +- UI: `ResourceUsageScreen` under `ui/screen/loggedIn/settings/`. + +### Wiring (AppModules / Amethyst / hooks) +- store + accountant + integrator constructed in `AppModules`; listener added + via `client.addConnectionListener`. +- `ForegroundTracker` registered in `Amethyst.onCreate` (main process only). +- `RoleBasedHttpClientBuilder` gains an optional usage meter. +- `EventNotificationConsumer.withWakeLock` gains an optional held-duration + callback (threaded through `NotificationDispatcher`). +- Workers increment their run counters via `Amethyst.instance` (guarded). +- `AppModules.trim()` flushes the accountant (backgrounding = natural flush). + +### Alert thresholds (v1, deliberately conservative — tune with real reports) +Evaluated on the last *complete* day, OR today once exceeded: +- background cellular traffic > 50 MB/day +- relay connection time > 12 relay-hours/day while backgrounded on cellular +- notification wakelock held > 30 min/day +- process starts > 75/day +Rate limit: at most one prompt per 7 days; "don't ask again" persisted. +Never auto-sends: every path goes through the DM composer where the user sees +exactly what will be sent and must tap Send. + +### Privacy +Counters are sizes, durations, and counts — no URLs, no relay names, no event +content. The report includes device model fields identical to the crash +report. Everything stays on-device until the user explicitly sends the DM +(NIP-40 30-day expiration, same as crash reports). + +### Explicitly out of scope (v1) +- Layer 1 (Perfetto/ODPM macrobenchmarks) and Layer 2 (`TrafficStats` socket + tags) — add only if the ledger proves blind somewhere (e.g. WS bytes are + payload-approximate; TrafficStats would give exact on-wire bytes). +- Per-relay attribution in the ledger (RelayStats screens already exist). +- Desktop: accountant/store are Android-module for now; extraction to commons + is mechanical if desktop wants it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 31b4ea27e8..c615047be8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -110,6 +110,10 @@ class Amethyst : Application() { // kdoc for the threshold rationale. registerActivityLifecycleCallbacks(AppForegroundRecycleHook()) + // Foreground signal for the resource-usage ledger (fg/bg attribution + // of bytes and connection-time). Main process only. + registerActivityLifecycleCallbacks(instance.foregroundTracker) + if (isDebug) { Logging.setup() // Auto-enable the Nests session-trace recorder in debug diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0d1a92ff48..bb2f1700f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -86,6 +86,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker +import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator +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.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate @@ -279,6 +286,24 @@ class AppModules( // on Tor-enabled clients to transparently redirect to .onion addresses. val onionLocationCache = OnionLocationCache() + // ---- Resource-usage ledger (battery/data accounting) ---- + // Passive on-device counters (bytes per subsystem x network x visibility, + // relay connection-time, wakelock time, worker runs). Never transmitted; + // the user can review them in Settings and explicitly DM a report to the + // developers. See amethyst/plans/2026-07-12-resource-usage-ledger.md. + val foregroundTracker = ForegroundTracker() + + val resourceUsageStore = ResourceUsageStore(File(appContext.filesDir, ResourceUsageStore.FILE_NAME)) + + val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope) + + private val httpUsageMeter = + HttpUsageMeter( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + ) + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -301,7 +326,7 @@ class AppModules( ) // Offers easy methods to know when connections are happening through Tor or not - val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) + val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value, httpUsageMeter) val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") @@ -605,6 +630,23 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) + // Resource-usage ledger: relay traffic + connection-time collectors. + init { + client.addConnectionListener( + RelayUsageListener( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + ), + ) + RelayConnectionTimeIntegrator( + connectedCount = client.connectedRelaysFlow().map { it.size }, + isMobile = connManager.isMobileOrNull, + isForeground = foregroundTracker.isForeground, + accountant = resourceUsage, + ).start(applicationIOScope) + } + // Logs debug messages when needed val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null @@ -740,7 +782,11 @@ class AppModules( // Observes LocalCache for notification-relevant events and routes them to // EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay // subscriptions, and NotificationRelayService. - val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope) + val notificationDispatcher = + NotificationDispatcher(appContext, applicationIOScope) { heldMs -> + resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_MS, heldMs) + resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_COUNT, 1) + } // Local store for posts the user has scheduled to publish later. Backed by a // single JSON file under the app's private filesDir; read by ScheduledPostWorker. @@ -824,6 +870,10 @@ class AppModules( fun initiate(appContext: Context) { Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) + // Ledger: count process starts — high counts reveal WorkManager/restart + // churn that cold-starts the whole app graph repeatedly. + resourceUsage.add(UsageKeys.APP_STARTS, 1) + // Restore the persisted DNS cache before any networking starts. Lookups that fire // before this completes fall through to the sync resolver path (existing behavior); // once restored, every previously-seen host hits the stale-while-revalidate path @@ -1005,6 +1055,8 @@ class AppModules( fun trim(level: Int) { _trimLevelEvents.tryEmit(level) + // Backgrounding is a natural moment to flush the usage ledger too. + resourceUsage.flushAsync() applicationIOScope.launch { // Backgrounding is a natural moment to flush the DNS cache. dnsStore.save() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index a332044be6..b2ca4dcafc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model.privacyOptions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager +import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient @@ -32,7 +34,18 @@ import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, val torSettings: TorSettingsFlow, + /** + * When present, every role's client is wrapped with a byte-counting + * interceptor so the resource-usage ledger can attribute HTTP traffic + * per subsystem. Null keeps the raw shared clients (tests). + */ + val usageMeter: HttpUsageMeter? = null, ) : IRoleBasedHttpClientBuilder { + private fun metered( + role: String, + base: OkHttpClient, + ): OkHttpClient = usageMeter?.counted(role, base) ?: base + fun shouldUseTorForImageDownload(url: String) = shouldUseTorFor( url, @@ -131,19 +144,19 @@ class RoleBasedHttpClientBuilder( override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) - override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url)) + override fun okHttpClientForNip05(url: String): OkHttpClient = metered(UsageKeys.ROLE_NIP05, okHttpClient.getHttpClient(shouldUseTorForNIP05(url))) - override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url)) + override fun okHttpClientForUploads(url: String): OkHttpClient = metered(UsageKeys.ROLE_UPLOADS, okHttpClient.getHttpClient(shouldUseTorForUploads(url))) - override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)) + override fun okHttpClientForImage(url: String): OkHttpClient = metered(UsageKeys.ROLE_IMAGE, okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))) - override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)) + override fun okHttpClientForVideo(url: String): OkHttpClient = metered(UsageKeys.ROLE_VIDEO, okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))) - override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)) + override fun okHttpClientForMoney(url: String): OkHttpClient = metered(UsageKeys.ROLE_MONEY, okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))) - override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) + override fun okHttpClientForPreview(url: String): OkHttpClient = metered(UsageKeys.ROLE_PREVIEW, okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))) - override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + override fun okHttpClientForPushRegistration(url: String): OkHttpClient = metered(UsageKeys.ROLE_PUSH, okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())) /** * Returns a [SocketFactory] that routes through the user's Tor proxy diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index a68089408f..60453031bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -26,9 +26,11 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -60,6 +62,7 @@ class CalendarReminderWorker( params: WorkerParameters, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) } val prefs = CalendarReminderPrefs(applicationContext) if (!prefs.isEnabled()) { Log.d(TAG) { "Reminders disabled; ending periodic chain." } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 8b048326b9..123a03e162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -24,6 +24,7 @@ import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable import android.os.PowerManager +import android.os.SystemClock import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.asDrawable @@ -108,6 +109,8 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" class EventNotificationConsumer( private val applicationContext: Context, + /** Reports how long each notification-processing wakelock was held (resource-usage ledger). */ + private val onWakeLockHeld: ((heldMs: Long) -> Unit)? = null, ) { companion object { private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes @@ -126,6 +129,7 @@ class EventNotificationConsumer( PowerManager.PARTIAL_WAKE_LOCK, "amethyst:notification_processing", ) + val heldSince = SystemClock.elapsedRealtime() wakeLock.acquire(WAKELOCK_TIMEOUT_MS) try { return block() @@ -133,6 +137,7 @@ class EventNotificationConsumer( if (wakeLock.isHeld) { wakeLock.release() } + onWakeLockHeld?.invoke(SystemClock.elapsedRealtime() - heldSince) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt index 610e57a859..d11a88bd00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt @@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -120,6 +121,7 @@ class NotificationCatchUpWorker( override suspend fun doWork(): Result { Log.d(TAG, "Starting notification catch-up") + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("notificationCatchUp"), 1) } return try { // If the foreground service should be running but isn't, restart it diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 37e6fb2f96..1f583ded83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -80,6 +80,8 @@ import kotlinx.coroutines.launch class NotificationDispatcher( private val context: Context, private val scope: CoroutineScope, + /** Forwarded to [EventNotificationConsumer]: reports wakelock held-time to the resource-usage ledger. */ + onWakeLockHeld: ((heldMs: Long) -> Unit)? = null, ) { companion object { private const val TAG = "NotificationDispatcher" @@ -127,7 +129,7 @@ class NotificationDispatcher( ) } - private val consumer = EventNotificationConsumer(context) + private val consumer = EventNotificationConsumer(context, onWakeLockHeld) private var job: Job? = null fun start() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt new file mode 100644 index 0000000000..f51ec4e2f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -0,0 +1,159 @@ +/* + * 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 androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.pluralStringResource +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Developer recipient for user-initiated diagnostic DMs — the same account + * the crash-report dialog routes to. + */ +const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + +/** + * On app open, checks the resource-usage ledger against the + * [ResourceUsageAlerts] thresholds and — at most once per week, unless the + * user opted out — asks whether they'd like to review + send a usage report + * to the developers. Confirming only PREFILLS the NIP-17 DM composer (crash + * report pattern): the full report text is visible there and nothing is sent + * until the user taps Send. + */ +@Composable +fun DisplayResourceUsageAlert( + accountViewModel: AccountViewModel, + nav: INav, +) { + val alert = remember { mutableStateOf(null) } + + LaunchedEffect(accountViewModel) { + withContext(Dispatchers.IO) { + val store = Amethyst.instance.resourceUsageStore + val accountant = Amethyst.instance.resourceUsage + if (!ResourceUsageAlerts.shouldPrompt(store.lastAlertAtSec(), store.alertsOptOut(), TimeUtils.now())) { + return@withContext + } + val found = ResourceUsageAlerts.evaluate(accountant.allDaysIncludingLive(), accountant.today()) + if (found != null) { + // Mark immediately so process restarts can't re-prompt within the window. + store.markAlertPrompted(TimeUtils.now()) + alert.value = found + } + } + } + + alert.value?.let { found -> + AlertDialog( + onDismissRequest = { alert.value = null }, + title = { Text(stringRes(R.string.resource_usage_alert_title)) }, + text = { + Text( + stringRes( + R.string.resource_usage_alert_message, + reasonDescription(found), + ), + ) + }, + dismissButton = { + Row { + TextButton(onClick = { + accountViewModel.runOnIO { + Amethyst.instance.resourceUsageStore.setAlertsOptOut(true) + } + alert.value = null + }) { + Text(stringRes(R.string.resource_usage_alert_opt_out)) + } + TextButton(onClick = { alert.value = null }) { + Text(stringRes(R.string.resource_usage_alert_not_now)) + } + } + }, + confirmButton = { + Button(onClick = { + nav.nav { + val report = + ResourceUsageReportAssembler().buildReport( + Amethyst.instance.resourceUsage.allDaysIncludingLive(), + Amethyst.instance.resourceUsage.today(), + ) + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + alert.value = null + }) { + Text(stringRes(R.string.resource_usage_alert_send)) + } + }, + ) + } +} + +@Composable +private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String = + when (alert.reason) { + ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA -> + stringRes( + R.string.resource_usage_reason_bg_data, + ResourceUsageReportAssembler.formatBytes(alert.value), + ) + + ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME -> + stringRes( + R.string.resource_usage_reason_conn_time, + ResourceUsageReportAssembler.formatConnHours(alert.value), + ) + + ResourceUsageAlerts.Reason.WAKELOCK_TIME -> + stringRes( + R.string.resource_usage_reason_wakelock, + ResourceUsageReportAssembler.formatDurationMs(alert.value), + ) + + ResourceUsageAlerts.Reason.PROCESS_CHURN -> + pluralStringResource( + R.plurals.resource_usage_reason_churn, + alert.value.toInt(), + alert.value.toInt(), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt new file mode 100644 index 0000000000..0c4796a6a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt @@ -0,0 +1,69 @@ +/* + * 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.app.Activity +import android.app.Application +import android.os.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-level foreground signal as an observable StateFlow: true while at + * least one activity is STARTED. Used by the usage ledger to attribute bytes + * and connection-time to foreground vs background buckets. + * + * ([MainActivity.isResumed] is not observable and goes false during PiP / + * in-app dialogs, which would misattribute foreground traffic to background.) + */ +class ForegroundTracker : Application.ActivityLifecycleCallbacks { + private var startedActivities = 0 + + private val _isForeground = MutableStateFlow(false) + val isForeground: StateFlow = _isForeground.asStateFlow() + + override fun onActivityStarted(activity: Activity) { + startedActivities++ + _isForeground.value = startedActivities > 0 + } + + override fun onActivityStopped(activity: Activity) { + startedActivities = (startedActivities - 1).coerceAtLeast(0) + _isForeground.value = startedActivities > 0 + } + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityDestroyed(activity: Activity) {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt new file mode 100644 index 0000000000..7fea7880eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt @@ -0,0 +1,92 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Integrates relay-connection-time (Σ open connections × elapsed time) into + * the ledger, split by network class and visibility. Connection-time is the + * best single battery proxy the ping study found: most relays server-ping + * every 30-70s, so the radio is active for as long as connections are open. + * + * No timers: the integral is exact between state changes (the connection + * count is constant), so a segment is closed only when any input changes — + * plus on [closeOpenSegment], which the accountant calls before every flush + * and read so multi-hour stable sessions still account. + */ +class RelayConnectionTimeIntegrator( + private val connectedCount: Flow, + private val isMobile: Flow, + private val isForeground: Flow, + private val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + private data class SegmentState( + val count: Int, + val mobile: Boolean, + val foreground: Boolean, + ) + + private val lock = Any() + private var current: SegmentState? = null + private var segmentStartMs: Long = 0L + + fun start(scope: CoroutineScope): Job { + accountant.addPreFlushHook(::closeOpenSegment) + return scope.launch { + combine(connectedCount, isMobile, isForeground) { count, mobile, fg -> + SegmentState(count, mobile ?: false, fg) + }.collect { next -> transitionTo(next) } + } + } + + /** Accounts the running segment up to now without changing state. */ + fun closeOpenSegment() { + synchronized(lock) { + val state = current ?: return + val now = nowMs() + account(state, now - segmentStartMs) + segmentStartMs = now + } + } + + private fun transitionTo(next: SegmentState) { + synchronized(lock) { + val now = nowMs() + current?.let { account(it, now - segmentStartMs) } + current = next + segmentStartMs = now + } + } + + private fun account( + state: SegmentState, + elapsedMs: Long, + ) { + if (state.count <= 0 || elapsedMs <= 0) return + accountant.add(UsageKeys.relayConnMs(state.mobile, state.foreground), state.count * elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt new file mode 100644 index 0000000000..190649036f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -0,0 +1,58 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command + +/** + * Counts relay websocket traffic into the usage ledger. Frame sizes are + * UTF-16 char counts of the JSON payload — a close proxy for on-wire bytes + * (relay JSON is ASCII-dominant), consistent with how RelayStats counts. + * Excludes WS framing/compression; good enough for "which subsystem is + * eating my data plan" comparisons. + */ +class RelayUsageListener( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) : RelayConnectionListener { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (success) { + accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong()) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt new file mode 100644 index 0000000000..0b19a1bf1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -0,0 +1,120 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.LongAdder + +/** + * In-memory hot path for usage counters. [add] is called from network threads + * per relay frame / HTTP response chunk, so it must be allocation-light and + * lock-free: a ConcurrentHashMap of LongAdders, drained into + * [ResourceUsageStore] by a debounced flush (at most one write per + * [flushDebounceMs] while traffic flows; nothing scheduled when idle). + * + * Day attribution: deltas are drained into the bucket of the day they are + * drained on. Counts within one debounce window of midnight may land on the + * neighboring day — irrelevant at the ledger's day-level granularity. + */ +class ResourceUsageAccountant( + private val store: ResourceUsageStore, + private val scope: CoroutineScope, + private val epochDay: () -> Long = { System.currentTimeMillis() / DAY_MS }, + private val flushDebounceMs: Long = 30_000L, +) { + private val live = ConcurrentHashMap() + private val flushScheduled = AtomicBoolean(false) + + /** Hooks run right before a flush drains the counters (e.g. the connection-time integrator closing its open segment). */ + private val preFlushHooks = ConcurrentHashMap.newKeySet<() -> Unit>() + + fun addPreFlushHook(hook: () -> Unit) { + preFlushHooks.add(hook) + } + + fun add( + key: String, + amount: Long, + ) { + if (amount <= 0) return + live.computeIfAbsent(key) { LongAdder() }.add(amount) + if (flushScheduled.compareAndSet(false, true)) { + scope.launch { + delay(flushDebounceMs) + flushScheduled.set(false) + flush() + } + } + } + + /** Drains the in-memory counters into today's persisted bucket. */ + suspend fun flush() { + preFlushHooks.forEach { runCatching { it() } } + val deltas = drain() + if (deltas.isNotEmpty()) { + store.mergeInto(epochDay(), deltas) + } + } + + /** Fire-and-forget flush for non-suspending callers (onTrimMemory). */ + fun flushAsync() { + scope.launch { flush() } + } + + fun today(): Long = epochDay() + + /** + * Persisted buckets merged with the not-yet-flushed live counters + * (attributed to today). This is what the UI, the report assembler, + * and the alert evaluator read. + */ + suspend fun allDaysIncludingLive(): Map> { + preFlushHooks.forEach { runCatching { it() } } + val persisted = store.allDays() + val liveSnapshot = live.mapValues { it.value.sum() }.filterValues { it > 0 } + if (liveSnapshot.isEmpty()) return persisted + val today = epochDay() + val merged = persisted.toMutableMap() + val todayBucket = merged[today].orEmpty().toMutableMap() + liveSnapshot.forEach { (key, value) -> todayBucket[key] = (todayBucket[key] ?: 0L) + value } + merged[today] = todayBucket + return merged + } + + private fun drain(): Map { + if (live.isEmpty()) return emptyMap() + val deltas = mutableMapOf() + for (key in live.keys) { + val adder = live.remove(key) ?: continue + val value = adder.sum() + if (value > 0) deltas[key] = value + } + return deltas + } + + companion object { + const val DAY_MS = 24L * 60L * 60L * 1000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt new file mode 100644 index 0000000000..4180ef78cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt @@ -0,0 +1,96 @@ +/* + * 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 + +/** + * Pure threshold logic for the "this app is consuming too much" prompt. + * Thresholds are deliberately conservative: the prompt should fire for the + * pathological cases (a stuck reconnect loop, process-restart churn, runaway + * background sync) — not for a heavy day of normal use. Tune them as real + * reports come in. + * + * Never auto-sends anything: a positive evaluation only ASKS the user, at + * most once every [MIN_DAYS_BETWEEN_PROMPTS] days, and respects a permanent + * opt-out. Both are persisted in [ResourceUsageStore]. + */ +object ResourceUsageAlerts { + enum class Reason { + BACKGROUND_MOBILE_DATA, + BACKGROUND_MOBILE_CONNECTION_TIME, + WAKELOCK_TIME, + PROCESS_CHURN, + } + + data class Alert( + val reason: Reason, + val day: Long, + val value: Long, + ) + + /** > 50 MB of background traffic on cellular in one day. */ + const val BG_MOBILE_BYTES_PER_DAY = 50L * 1024L * 1024L + + /** > 12 relay-connection-hours while backgrounded on cellular in one day. */ + const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 12L * 60L * 60L * 1000L + + /** > 30 minutes of notification wakelock held in one day. */ + const val WAKELOCK_MS_PER_DAY = 30L * 60L * 1000L + + /** > 75 process starts in one day (WorkManager/restart churn). */ + const val APP_STARTS_PER_DAY = 75L + + const val MIN_DAYS_BETWEEN_PROMPTS = 7L + + /** + * Checks yesterday (the last complete day) first, then today (so a + * runaway condition surfaces without waiting for midnight). Returns the + * first threshold crossed or null. + */ + fun evaluate( + days: Map>, + today: Long, + ): Alert? { + for (day in longArrayOf(today - 1, today)) { + val counters = days[day] ?: continue + val summary = UsageSummary.from(counters) + + if (summary.mobileBytesBg > BG_MOBILE_BYTES_PER_DAY) { + return Alert(Reason.BACKGROUND_MOBILE_DATA, day, summary.mobileBytesBg) + } + if (summary.relayConnMsMobileBg > BG_MOBILE_RELAY_CONN_MS_PER_DAY) { + return Alert(Reason.BACKGROUND_MOBILE_CONNECTION_TIME, day, summary.relayConnMsMobileBg) + } + if (summary.wakelockMs > WAKELOCK_MS_PER_DAY) { + return Alert(Reason.WAKELOCK_TIME, day, summary.wakelockMs) + } + if (summary.appStarts > APP_STARTS_PER_DAY) { + return Alert(Reason.PROCESS_CHURN, day, summary.appStarts) + } + } + return null + } + + fun shouldPrompt( + lastAlertAtSec: Long, + optOut: Boolean, + nowSec: Long, + ): Boolean = !optOut && nowSec - lastAlertAtSec >= MIN_DAYS_BETWEEN_PROMPTS * 24L * 60L * 60L +} 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 new file mode 100644 index 0000000000..47c8493b8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -0,0 +1,114 @@ +/* + * 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.Build +import com.vitorpamplona.amethyst.BuildConfig +import java.util.Locale + +/** + * Assembles the Markdown resource-usage report the user can DM to the + * developers via NIP-17 — same shape as the crash ReportAssembler: a device + * header table, a human-readable summary, then the full per-day counter dump + * as the technical payload. Counters are sizes/durations/counts only; no + * URLs, relay names, or content. + */ +class ResourceUsageReportAssembler { + fun buildReport( + days: Map>, + today: Long, + ): String { + val sb = StringBuilder() + sb.append("Resource Usage Report: ") + sb.append(BuildConfig.VERSION_NAME) + sb.append("-") + sb.append(BuildConfig.FLAVOR.uppercase()) + sb.append("\n\n") + + sb.append("| Prop | Value |\n") + sb.append("| --- | --- |\n") + sb.append("| Manuf | ${Build.MANUFACTURER} |\n") + sb.append("| Model | ${Build.MODEL} |\n") + sb.append("| Android | ${Build.VERSION.RELEASE} |\n") + sb.append("| SDK Int | ${Build.VERSION.SDK_INT} |\n") + sb.append("\n") + + val todayCounters = days[today].orEmpty() + val weekCounters = (today - 6..today).mapNotNull { days[it] } + + sb.append("**Today**\n\n") + sb.append(summaryTable(UsageSummary.from(todayCounters))) + sb.append("\n**Last 7 days**\n\n") + sb.append(summaryTable(UsageSummary.fromDays(weekCounters))) + + sb.append("\nTechnical details (per epoch-day):\n") + sb.append("```\n") + days.toSortedMap().forEach { (day, counters) -> + sb.append("day $day (today=$today)\n") + counters.toSortedMap().forEach { (key, value) -> + sb.append(" $key = $value\n") + } + } + sb.append("```\n") + return sb.toString() + } + + private fun summaryTable(s: UsageSummary): String = + buildString { + append("| Metric | Value |\n") + append("| --- | --- |\n") + append("| Cellular data (background) | ${formatBytes(s.mobileBytesBg)} |\n") + append("| Cellular data (foreground) | ${formatBytes(s.mobileBytesFg)} |\n") + append("| Wi-Fi data | ${formatBytes(s.wifiBytesBg + s.wifiBytesFg)} |\n") + append("| Relay connection time | ${formatConnHours(s.relayConnMs)} |\n") + append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") + append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") + append("| Background worker runs | ${s.workerRuns} |\n") + append("| App process starts | ${s.appStarts} |\n") + val subsystems = + s.bytesPerSubsystem.entries + .sortedByDescending { it.value } + .joinToString(", ") { "${it.key} ${formatBytes(it.value)}" } + if (subsystems.isNotEmpty()) { + append("| By subsystem | $subsystems |\n") + } + } + + companion object { + fun formatBytes(bytes: Long): String = + when { + bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)) + bytes >= 1024L * 1024L -> String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0)) + bytes >= 1024L -> String.format(Locale.US, "%.1f KB", bytes / 1024.0) + else -> "$bytes B" + } + + /** Relay-connection time: Σ connections x time, so shown as "relay-hours". */ + fun formatConnHours(ms: Long): String = String.format(Locale.US, "%.1f relay-hours", ms / (1000.0 * 60.0 * 60.0)) + + fun formatDurationMs(ms: Long): String = + when { + ms >= 60L * 60L * 1000L -> String.format(Locale.US, "%.1f h", ms / (1000.0 * 60.0 * 60.0)) + ms >= 60L * 1000L -> String.format(Locale.US, "%.1f min", ms / (1000.0 * 60.0)) + else -> String.format(Locale.US, "%.1f s", ms / 1000.0) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt new file mode 100644 index 0000000000..7e7824631b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -0,0 +1,154 @@ +/* + * 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 com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +/** + * Durable daily buckets for the resource-usage ledger, one JSON file in the + * app's private filesDir. Same persistence idiom as ScheduledPostStore: + * Jackson + Mutex + write-to-tmp-then-rename + version envelope. + * + * Day keys are UTC epoch-days (stringified for JSON). Buckets older than + * [keepDays] are pruned on every merge, so the file stays small (a few KB). + * Also carries the high-consumption alert state (last prompt time, opt-out) + * so the whole feature has exactly one file. + */ +class ResourceUsageStore( + private val storageFile: File, + private val keepDays: Long = 30, +) { + data class UsageFile( + val version: Int = 1, + val days: Map> = emptyMap(), + val lastAlertAtSec: Long = 0L, + val alertsOptOut: Boolean = false, + ) + + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + private val mutex = Mutex() + private var loaded = false + private var data = UsageFile() + + suspend fun mergeInto( + day: Long, + deltas: Map, + ) { + if (deltas.isEmpty()) return + mutex.withLock { + ensureLoaded() + val dayKey = day.toString() + val bucket = data.days[dayKey].orEmpty().toMutableMap() + deltas.forEach { (key, amount) -> bucket[key] = (bucket[key] ?: 0L) + amount } + val pruned = + data.days + .filterKeys { (it.toLongOrNull() ?: Long.MAX_VALUE) >= day - keepDays } + .toMutableMap() + pruned[dayKey] = bucket + data = data.copy(days = pruned) + persist() + } + } + + /** All persisted daily buckets, keyed by epoch-day. */ + suspend fun allDays(): Map> = + mutex.withLock { + ensureLoaded() + data.days.mapNotNull { (k, v) -> k.toLongOrNull()?.let { it to v } }.toMap() + } + + suspend fun lastAlertAtSec(): Long = + mutex.withLock { + ensureLoaded() + data.lastAlertAtSec + } + + suspend fun alertsOptOut(): Boolean = + mutex.withLock { + ensureLoaded() + data.alertsOptOut + } + + suspend fun markAlertPrompted(atSec: Long) = + mutex.withLock { + ensureLoaded() + data = data.copy(lastAlertAtSec = atSec) + persist() + } + + suspend fun setAlertsOptOut(optOut: Boolean) = + mutex.withLock { + ensureLoaded() + data = data.copy(alertsOptOut = optOut) + persist() + } + + private fun ensureLoaded() { + if (loaded) return + data = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile) + } else { + UsageFile() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load resource usage from $storageFile", e) + UsageFile() + } + loaded = true + } + + private fun persist() { + storageFile.parentFile?.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, data) + if (!tmp.renameTo(storageFile)) { + if (!storageFile.delete() || !tmp.renameTo(storageFile)) { + Log.e(TAG) { "Failed to rename $tmp to $storageFile" } + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to persist resource usage to $storageFile", e) + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + + companion object { + private const val TAG = "ResourceUsageStore" + const val FILE_NAME = "resource_usage.json" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt new file mode 100644 index 0000000000..296b038e86 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -0,0 +1,124 @@ +/* + * 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 okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.ForwardingSource +import okio.Source +import okio.buffer +import java.util.concurrent.ConcurrentHashMap + +/** + * Application interceptor that attributes HTTP traffic to a ledger subsystem + * (image/video/uploads/money/nip05/preview/push). Upload size comes from the + * request body's contentLength; download size is counted as the app actually + * consumes the streamed response body, so partially-read streams (video seek, + * cancelled image loads) count only what crossed the wire to the app. + * + * Network/visibility dims are sampled when bytes flow, not when the request + * is created — a request issued on wifi that finishes on cellular counts as + * cellular, matching what the radio actually did. + */ +class UsageCountingInterceptor( + private val role: String, + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L + if (requestBytes > 0) { + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes) + } + + val response = chain.proceed(request) + val body = response.body + return response + .newBuilder() + .body( + CountingResponseBody(body) { bytes -> + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) + }, + ).build() + } + + private class CountingResponseBody( + private val delegate: ResponseBody, + private val onBytes: (Long) -> Unit, + ) : ResponseBody() { + override fun contentType() = delegate.contentType() + + override fun contentLength() = delegate.contentLength() + + private val countedSource by lazy { + object : ForwardingSource(delegate.source() as Source) { + override fun read( + sink: Buffer, + byteCount: Long, + ): Long { + val read = super.read(sink, byteCount) + if (read > 0) onBytes(read) + return read + } + }.buffer() + } + + override fun source() = countedSource + } +} + +/** + * Caches per-(role, base client) wrapped OkHttp clients so each role gets its + * counting interceptor without rebuilding the shared clients. Base clients + * are rebuilt on proxy/network changes (new identity), so the cache is + * cleared when it grows past a small bound. + */ +class HttpUsageMeter( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) { + private val wrapped = ConcurrentHashMap, OkHttpClient>() + + fun counted( + role: String, + base: OkHttpClient, + ): OkHttpClient { + if (wrapped.size > MAX_CACHED) wrapped.clear() + return wrapped.getOrPut(role to base) { + base + .newBuilder() + .addInterceptor(UsageCountingInterceptor(role, accountant, isMobile, isForeground)) + .build() + } + } + + companion object { + // roles (7) x live base clients (proxy on/off ~2) with headroom for + // network-change rebuilds before the clear kicks in. + private const val MAX_CACHED = 32 + } +} 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 new file mode 100644 index 0000000000..ef04e0ea55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -0,0 +1,96 @@ +/* + * 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 + +/** + * Counter-key grammar for the resource-usage ledger. Keys are flat strings so + * the on-disk store is schema-free — adding a counter never needs a migration. + * + * Dimensions: + * - network: `mobile` (cellular/metered) vs `wifi` (everything else) + * - visibility: `fg` (an activity is started) vs `bg` + * - direction: `rx` (downloaded) vs `tx` (uploaded) + * + * Counters are sizes, durations, and counts only — never URLs, relay names, or + * content. See plans/2026-07-12-resource-usage-ledger.md. + */ +object UsageKeys { + const val MOBILE = "mobile" + const val WIFI = "wifi" + const val FG = "fg" + const val BG = "bg" + const val RX = "rx" + const val TX = "tx" + + /** HTTP subsystems, matching IRoleBasedHttpClientBuilder's roles. */ + const val ROLE_IMAGE = "image" + const val ROLE_VIDEO = "video" + const val ROLE_UPLOADS = "uploads" + const val ROLE_MONEY = "money" + const val ROLE_NIP05 = "nip05" + const val ROLE_PREVIEW = "preview" + const val ROLE_PUSH = "push" + + val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH) + + /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ + fun net( + role: String, + mobile: Boolean, + foreground: Boolean, + received: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.${if (received) RX else TX}" + + /** `relay.msg.mobile.bg.rx` — approximate relay websocket payload bytes. */ + fun relayMsg( + mobile: Boolean, + foreground: Boolean, + received: Boolean, + ): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}" + + /** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */ + fun relayConnMs( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connms.${dim(mobile, foreground)}" + + /** `worker.scheduledPost.runs` */ + fun workerRuns(worker: String): String = "worker.$worker.runs" + + const val WAKELOCK_NOTIF_MS = "wakelock.notif.ms" + const val WAKELOCK_NOTIF_COUNT = "wakelock.notif.count" + const val APP_STARTS = "app.starts" + + fun dim( + mobile: Boolean, + foreground: Boolean, + ): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}" + + /** Sums every counter whose key matches all the given dot-delimited parts. */ + fun Map.sumMatching(vararg parts: String): Long { + var total = 0L + for ((key, value) in this) { + val segments = key.split('.') + if (parts.all { it in segments }) total += value + } + return total + } +} 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 new file mode 100644 index 0000000000..1e6d7e6b38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -0,0 +1,89 @@ +/* + * 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 com.vitorpamplona.amethyst.service.resourceusage.UsageKeys.sumMatching + +/** + * Headline metrics derived from one or more daily counter buckets. Shared by + * the usage screen, the NIP-17 report, and the high-consumption alerts so + * every surface agrees on the numbers. + */ +data class UsageSummary( + val mobileBytesBg: Long, + val mobileBytesFg: Long, + val wifiBytesBg: Long, + val wifiBytesFg: Long, + val relayConnMsMobileBg: Long, + val relayConnMsMobileFg: Long, + val relayConnMsWifiBg: Long, + val relayConnMsWifiFg: Long, + val wakelockMs: Long, + val wakelockCount: Long, + val workerRuns: Long, + val appStarts: Long, + /** total rx+tx bytes per subsystem (net roles + "relay"). */ + val bytesPerSubsystem: Map, +) { + 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 traffic( + net: String, + vis: String, + ) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX) + + val subsystems = 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 relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX) + if (relayBytes > 0) subsystems["relay"] = relayBytes + + return UsageSummary( + mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG), + mobileBytesFg = traffic(UsageKeys.MOBILE, UsageKeys.FG), + wifiBytesBg = traffic(UsageKeys.WIFI, UsageKeys.BG), + wifiBytesFg = traffic(UsageKeys.WIFI, UsageKeys.FG), + relayConnMsMobileBg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.BG), + relayConnMsMobileFg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.FG), + relayConnMsWifiBg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.BG), + relayConnMsWifiFg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.FG), + wakelockMs = counters[UsageKeys.WAKELOCK_NOTIF_MS] ?: 0L, + wakelockCount = counters[UsageKeys.WAKELOCK_NOTIF_COUNT] ?: 0L, + workerRuns = counters.sumMatching("worker", "runs"), + appStarts = counters[UsageKeys.APP_STARTS] ?: 0L, + bytesPerSubsystem = subsystems, + ) + } + + /** Merges several day buckets and summarizes the total. */ + fun fromDays(days: Collection>): UsageSummary { + val merged = mutableMapOf() + days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } } + return from(merged) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index 6663d98463..40d3e4fae8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -132,6 +133,7 @@ class ScheduledPostWorker( override suspend fun doWork(): Result { val nowSec = System.currentTimeMillis() / 1000 Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" } + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("scheduledPost"), 1) } return try { val appModules = Amethyst.instance 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 0be3941fad..e5e7c77fa2 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 @@ -48,6 +48,7 @@ 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.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen @@ -228,6 +229,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettin import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ResourceUsageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen @@ -306,6 +308,7 @@ fun AppNavigation( DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) + DisplayResourceUsageAlert(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) ObserveIncomingCalls(accountViewModel) @@ -481,6 +484,7 @@ fun BuildNavigation( composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } + composableFromEnd { ResourceUsageScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e16b15383d..15be5ed14d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -422,6 +422,8 @@ sealed class Route { @Serializable object CalendarReminderSettings : Route() + @Serializable object ResourceUsage : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( 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 new file mode 100644 index 0000000000..06324565ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -0,0 +1,221 @@ +/* + * 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.ui.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.resourceusage.DEV_REPORT_PUBKEY +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.UsageSummary +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The resource-usage ledger: how much network, relay connection time, and + * background activity the app consumed, per subsystem — with an explicit, + * user-initiated path to DM the numbers to the developers (same NIP-17 flow + * as crash reports). Everything on this screen stays on-device until the + * user sends that DM. + */ +@Composable +fun ResourceUsageScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var days by remember { mutableStateOf>?>(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + days = Amethyst.instance.resourceUsage.allDaysIncludingLive() + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.resource_usage_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + val loaded = days + if (loaded == null) { + Text( + text = stringRes(R.string.resource_usage_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val today = Amethyst.instance.resourceUsage.today() + val todaySummary = UsageSummary.from(loaded[today].orEmpty()) + val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) + + UsageSummarySection(R.string.resource_usage_today, todaySummary) + UsageSummarySection(R.string.resource_usage_week, weekSummary) + SubsystemSection(weekSummary) + SendReportSection(accountViewModel, nav, loaded, today) + } + } + } +} + +@Composable +private fun UsageSummarySection( + @StringRes title: Int, + s: UsageSummary, +) { + SettingsSection(title) { + MetricRow(R.string.resource_usage_cellular_bg, formatBytes(s.mobileBytesBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_cellular_fg, formatBytes(s.mobileBytesFg)) + SettingsDivider() + MetricRow(R.string.resource_usage_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + } +} + +@Composable +private fun SubsystemSection(week: UsageSummary) { + if (week.bytesPerSubsystem.isEmpty()) return + SettingsSection(R.string.resource_usage_by_subsystem) { + val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } + rows.forEachIndexed { index, (subsystem, bytes) -> + if (index > 0) SettingsDivider() + MetricRow(subsystemLabel(subsystem), formatBytes(bytes)) + } + } +} + +@StringRes +private fun subsystemLabel(subsystem: String): Int = + when (subsystem) { + "relay" -> R.string.resource_usage_subsystem_relay + "image" -> R.string.resource_usage_subsystem_image + "video" -> R.string.resource_usage_subsystem_video + "uploads" -> R.string.resource_usage_subsystem_uploads + "money" -> R.string.resource_usage_subsystem_money + "nip05" -> R.string.resource_usage_subsystem_nip05 + "preview" -> R.string.resource_usage_subsystem_preview + "push" -> R.string.resource_usage_subsystem_push + else -> R.string.resource_usage_subsystem_other + } + +@Composable +private fun MetricRow( + @StringRes label: Int, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun SendReportSection( + accountViewModel: AccountViewModel, + nav: INav, + days: Map>, + today: Long, +) { + SettingsSection(R.string.resource_usage_send_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_send_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + val report = ResourceUsageReportAssembler().buildReport(days, today) + nav.nav { + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_send_button)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 3ace415fd7..5e85e88c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -103,6 +103,7 @@ fun buildSettingsCatalog( symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings), symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings), symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings), + symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage), ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..0239d5a241 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3180,6 +3180,45 @@ Crash Report found Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared Send it + + App resource usage + battery data usage consumption power network diagnostics ledger + Today + Last 7 days + Cellular data (background) + Cellular data (in app) + Wi-Fi data + Relay connection time + \u2026 backgrounded on cellular + Notification processing (CPU awake) + Background jobs run + App process starts + Data by feature (7 days) + Relay sync + Images + Video & audio + Uploads + Wallet & zaps + Address verification + Link previews + Push registration + Other + No usage recorded yet. + Share with the developers + If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, or browsing details. Nothing is sent until you tap Send in the message screen. + Send report via DM + High resource usage detected + Amethyst consumed more than expected recently: %1$s. Would you like to send a usage report to the developers in an encrypted DM? You will see the full report before anything is sent. + %1$s of cellular data in the background in one day + %1$s of relay connections while backgrounded on cellular in one day + the CPU was held awake for %1$s processing notifications in one day + + the app process restarted %1$d time in one day + the app process restarted %1$d times in one day + + Review & send + Not now + Don\u2019t ask again This message will disappear in %1$s Select Signer 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 new file mode 100644 index 0000000000..7a9c0cf9c1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -0,0 +1,295 @@ +/* + * 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ResourceUsageStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + @Before + fun setUp() { + file = File(temp.root, ResourceUsageStore.FILE_NAME) + } + + @Test + fun mergesAndReloadsFromDisk() = + runTest { + val store = ResourceUsageStore(file) + store.mergeInto(100, mapOf("a" to 5L, "b" to 2L)) + store.mergeInto(100, mapOf("a" to 3L)) + store.mergeInto(101, mapOf("a" to 1L)) + + val reloaded = ResourceUsageStore(file).allDays() + assertEquals(8L, reloaded[100]?.get("a")) + assertEquals(2L, reloaded[100]?.get("b")) + assertEquals(1L, reloaded[101]?.get("a")) + } + + @Test + fun prunesBucketsOlderThanKeepDays() = + runTest { + val store = ResourceUsageStore(file, keepDays = 7) + store.mergeInto(100, mapOf("a" to 1L)) + store.mergeInto(110, mapOf("a" to 1L)) + + val days = store.allDays() + assertNull("day 100 is older than 110-7 and must be pruned", days[100]) + assertEquals(1L, days[110]?.get("a")) + } + + @Test + fun alertStateRoundTrips() = + runTest { + val store = ResourceUsageStore(file) + assertEquals(0L, store.lastAlertAtSec()) + assertFalse(store.alertsOptOut()) + + store.markAlertPrompted(12345L) + store.setAlertsOptOut(true) + + val reloaded = ResourceUsageStore(file) + assertEquals(12345L, reloaded.lastAlertAtSec()) + assertTrue(reloaded.alertsOptOut()) + } +} + +class ResourceUsageAccountantTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accumulatesAndFlushesIntoTheRightDay() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + var day = 200L + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { day }) + + accountant.add("x", 5) + accountant.add("x", 5) + accountant.flush() + day = 201L + accountant.add("x", 7) + accountant.flush() + + val days = store.allDays() + assertEquals(10L, days[200]?.get("x")) + assertEquals(7L, days[201]?.get("x")) + } + + @Test + fun liveCountersAreVisibleBeforeFlush() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + + accountant.add("x", 42) + val days = accountant.allDaysIncludingLive() + assertEquals(42L, days[300]?.get("x")) + // and not yet on disk + assertNull(store.allDays()[300]) + } + + @Test + fun preFlushHooksRunOnFlushAndRead() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + var hookRuns = 0 + accountant.addPreFlushHook { hookRuns++ } + + accountant.flush() + accountant.allDaysIncludingLive() + assertEquals(2, hookRuns) + } +} + +class RelayConnectionTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun integratesConnectionTimeAcrossStateChanges() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + + var now = 0L + val count = MutableStateFlow(0) + val mobile = MutableStateFlow(false) + val fg = MutableStateFlow(true) + + val integrator = + RelayConnectionTimeIntegrator( + connectedCount = count, + isMobile = mobile, + isForeground = fg, + accountant = accountant, + nowMs = { now }, + ) + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // 5 relays connected on wifi foreground for 10s + count.value = 5 + testScheduler.runCurrent() + now = 10_000L + + // switch to cellular background: closes the wifi segment + mobile.value = true + fg.value = false + testScheduler.runCurrent() + + // 5 relays on cellular background for 20s, then all disconnect + now = 30_000L + count.value = 0 + testScheduler.runCurrent() + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(5 * 10_000L, counters[UsageKeys.relayConnMs(mobile = false, foreground = true)]) + assertEquals(5 * 20_000L, counters[UsageKeys.relayConnMs(mobile = true, foreground = false)]) + job.cancel() + } + + @Test + fun closeOpenSegmentAccountsLongStableSessions() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + + var now = 0L + val count = MutableStateFlow(3) + val integrator = + RelayConnectionTimeIntegrator( + connectedCount = count, + isMobile = MutableStateFlow(true), + isForeground = MutableStateFlow(false), + accountant = accountant, + nowMs = { now }, + ) + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // hours pass with no state change at all (always-on background) + now = 2 * 60 * 60 * 1000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals( + "reading the ledger must account the still-open segment", + 3 * 2 * 60 * 60 * 1000L, + counters[UsageKeys.relayConnMs(mobile = true, foreground = false)], + ) + job.cancel() + } +} + +class ResourceUsageAlertsTest { + private fun day(vararg counters: Pair) = mapOf(*counters) + + @Test + fun quietUsageDoesNotAlert() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = false, received = true) to 1024L * 1024L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 60L * 60L * 1000L, + ), + ) + assertNull(ResourceUsageAlerts.evaluate(days, today = 10L)) + } + + @Test + fun backgroundMobileDataCrossingThresholdAlerts() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = false, received = true) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY + 1, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA, alert?.reason) + assertEquals(9L, alert?.day) + } + + @Test + fun foregroundMobileDataDoesNotTripTheBackgroundThreshold() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY * 10, + ), + ) + assertNull(ResourceUsageAlerts.evaluate(days, today = 10L)) + } + + @Test + fun connectionTimeCounterDoesNotLeakIntoByteThreshold() { + // relay.connms keys carry mobile+bg dims but are milliseconds, not + // bytes: they must never count toward the data threshold. + val days = + mapOf( + 9L to + day( + UsageKeys.relayConnMs(mobile = true, foreground = false) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY * 100, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME, alert?.reason) + } + + @Test + fun todayIsCheckedWhenYesterdayIsQuiet() { + val days = + mapOf( + 10L to day(UsageKeys.APP_STARTS to ResourceUsageAlerts.APP_STARTS_PER_DAY + 1), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.PROCESS_CHURN, alert?.reason) + } + + @Test + fun promptRateLimiting() { + val now = 1_000_000L + val week = ResourceUsageAlerts.MIN_DAYS_BETWEEN_PROMPTS * 24 * 60 * 60 + assertTrue(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = false, nowSec = now)) + assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = now - week + 10, optOut = false, nowSec = now)) + assertTrue(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = now - week - 10, optOut = false, nowSec = now)) + assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = true, nowSec = now)) + } +}