mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge pull request #3551 from vitorpamplona/claude/mobile-battery-optimization-s7vw4a
Battery: background-usage fixes, 122-relay ping study, and an on-device resource-usage ledger
This commit is contained in:
@@ -158,13 +158,17 @@ Google Play Services infrastructure.
|
||||
|
||||
### Layer 4: AlarmManager Watchdog (5 minutes)
|
||||
|
||||
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5
|
||||
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5
|
||||
minutes. The receiver checks if the service should be running and restarts it.
|
||||
|
||||
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
|
||||
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
|
||||
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
|
||||
wake the device from sleep, ensuring the check happens even in Doze.
|
||||
delivered), the watchdog will catch it within 5 minutes of the device being awake.
|
||||
The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of
|
||||
sleep every 5 minutes is a battery cost with no payoff, because a service restarted
|
||||
on a sleeping device can't do useful network work until the device wakes anyway.
|
||||
While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover
|
||||
delivery; the moment the device wakes, the pending watchdog alarm fires.
|
||||
|
||||
### Layer 5: WorkManager Periodic Catch-Up (15 minutes)
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
# WebSocket Ping Interval Study — 122 Production Relays
|
||||
|
||||
**Date:** 2026-07-12
|
||||
**Question:** Would relays drop Amethyst's connections if the client WebSocket
|
||||
ping interval were raised (e.g. 120s → 240s on mobile data to save battery)?
|
||||
Was the long-standing 120s value ever load-bearing, and what is the best
|
||||
middle ground?
|
||||
|
||||
**Answer (TL;DR):** Keep a single **120s** ping interval on every network.
|
||||
Raising it to 240s saves almost no battery — 90% of surveyed relays send
|
||||
their *own* pings every 30–70s, which OkHttp must answer, so the radio's
|
||||
wake cadence is set by the relays, not by our interval — and it starts
|
||||
dropping real relay tiers: 240s pings lose `relay.ditto.pub` (~240s idle
|
||||
timeout) and every `nostr1.com`-hosted relay (~300s tier); 300s pings even
|
||||
lose `relay.snort.social` (~600s tier). Lowering below 120s would only
|
||||
rescue a ~120s tier of 6/122 relays that already cycle today, at 2× the
|
||||
ping traffic on every other connection. 120s is, by measurement, the sweet
|
||||
spot it was presumably never designed to be.
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
`OkHttpClientFactoryForRelays` sets `pingInterval(120s)` on every relay
|
||||
WebSocket. During battery work the interval was tentatively doubled on
|
||||
mobile data on the theory that each client ping on an otherwise-idle
|
||||
cellular connection wakes the radio and pays the multi-second tail-energy
|
||||
cost. The maintainer asked the right question: *do we actually know how
|
||||
production relays react to different ping intervals?* Nobody had tested
|
||||
the 120s value. This study answers it empirically.
|
||||
|
||||
Two distinct drop mechanisms are in play:
|
||||
|
||||
1. **Relay/reverse-proxy idle timeouts** — testable from any vantage.
|
||||
2. **Carrier NAT idle timeouts** — only testable from a real cellular
|
||||
network (not from this environment; see §7).
|
||||
|
||||
## 2. Relay population
|
||||
|
||||
Production relays were harvested by fetching **600 kind:10002 (NIP-65)
|
||||
relay-list events** from indexer relays (`indexer.coracle.social`,
|
||||
`user.kindpag.es`) and counting `r`-tag references: **1,468 distinct
|
||||
relays**, ranked by how many users actually list them. The **top 140**
|
||||
(plus all Amethyst default relays) formed the test population.
|
||||
|
||||
- **122 relays accepted a WebSocket** from the test vantage.
|
||||
- 18 were unreachable *from a datacenter IP* (Cloudflare 403 challenges:
|
||||
`nostr.wine`, `relay.0xchat.com`; TCP resets: `relay.nostr.band`,
|
||||
`nostr.bitcoiner.social`, `relayable.org`, `nostr.fmt.wiz.biz`; plus
|
||||
ordinary 5xx/410s). These blocks are IP-reputation-based, not
|
||||
ping-related, and don't affect the conclusions — but they mean the
|
||||
study cannot speak for those relays.
|
||||
|
||||
## 3. Method
|
||||
|
||||
Three experiments, all through the same stack (Python `websocket-client`,
|
||||
TLS, one REQ per connection whose filter matches nothing, so the relay
|
||||
answers EOSE and the connection then carries zero application traffic).
|
||||
Server pings were always answered with pongs automatically (as OkHttp
|
||||
does) and logged.
|
||||
|
||||
- **Phase A — idle survival.** 140 relays, **zero client pings**, hold
|
||||
for **780s (13 min)**. Records: drop time, close code, server-ping
|
||||
timestamps. A relay surviving 780s of total client-ping silence proves
|
||||
*any* client interval ≤ 780s is safe for it.
|
||||
- **Phase B — ping efficacy.** Every Phase A dropper re-tested with
|
||||
client pings at **55 / 110 / 120 / 180 / 240 / 300s** (one connection
|
||||
per interval, window = observed idle timeout + 2 ping cycles + margin,
|
||||
capped at 780s). This distinguishes "pings reset the relay's idle
|
||||
timer" from "only data frames count".
|
||||
- **Case study —** `relay.ditto.pub` with 60s pings for 420s (it had
|
||||
dropped an idle connection at 257s while *its own* ping got our pong at
|
||||
123s — proving pongs don't reset its timer but client pings do).
|
||||
|
||||
## 4. Phase A results — idle survival with zero client pings
|
||||
|
||||
**99 of 122 relays (81%) survived 13 minutes of complete client-ping
|
||||
silence.** For four out of five relays, the client ping interval is
|
||||
irrelevant to connection survival at any plausible value.
|
||||
|
||||
The 23 droppers cluster into clean idle-timeout tiers:
|
||||
|
||||
| Tier | Count | Relays |
|
||||
|---|---|---|
|
||||
| < 30s (probe rejected / non-idle close) | 2 | `nostr.petrkr.net/strfry`, `next.nsite.run` |
|
||||
| **~60s** | 8 | `nostr.pareto.space` (47s), `nostr.vps.satsnode.xyz` (×2), `relay.mostro.network`, `nostr.bond/alpha`, `nostr.sgiath.dev`, `nostr.bitcoinplebs.de`, `nostr.schneimi.de` |
|
||||
| **~120s** | 8 | `cfrelay.snowcait.workers.dev` (118s), `nostr-verified.wellorder.net` (120.7s), `nostr-pub.wellorder.net` (120.8s), `git.shakespeare.diy` (125.7s), `nostr-relay.irgenius.org` (126.0s), `nostr-verif.slothy.win` (126.1s), `nostr.bit4use.com` (126.6s), `sendit.nosflare.com` (131.4s) |
|
||||
| **~240s** | 1 | `relay.ditto.pub` (240.9s; 257.1s in an earlier run) |
|
||||
| **~300s** | 2 | `david.nostr1.com` (300.5s), `dkkc.nostr1.com` (300.7s) — i.e. the **nostr1.com / relay.tools hosting tier** |
|
||||
| **~600s** | 2 | `nos.lol/<haven path>` (600.8s), `relay.snort.social` (601.0s) |
|
||||
|
||||
### Server-ping cadence (the finding that reframes the question)
|
||||
|
||||
Among the 99 relays that held an idle connection for the full window:
|
||||
|
||||
| Server→client ping cadence | Relays |
|
||||
|---|---|
|
||||
| ≤ 35s | 45 |
|
||||
| 36–70s | 37 |
|
||||
| ~300s | 8 |
|
||||
| no server pings at all | 9 |
|
||||
|
||||
**90 of 99 relays ping the client; 82 of them every ≤ 70s.** OkHttp
|
||||
answers every server ping with a pong regardless of the client-side
|
||||
`pingInterval`. So on a connected cellular device the radio is being
|
||||
woken every 30–70s *per connection* by the relays themselves. Changing
|
||||
the client interval from 120s to 240s does not change that cadence at
|
||||
all — the client ping is a rounding error in the connection's keepalive
|
||||
traffic. **The claimed battery saving of a longer client ping interval
|
||||
does not exist in practice.** (Corollary: the real mobile-battery lever
|
||||
is connected time and connection count in the background — which the
|
||||
app already minimizes by disconnecting 30s after backgrounding — not
|
||||
the ping schedule.)
|
||||
|
||||
## 5. Phase B results — which client intervals keep the droppers alive
|
||||
|
||||
For every idle-dropper, one connection per candidate interval
|
||||
(`x@T` = dropped at T seconds despite pinging at that interval;
|
||||
`skip` = interval ≥ observed idle timeout, unsafe by construction):
|
||||
|
||||
| relay | idle-drop | 55s | 110s | 120s | 180s | 240s | 300s | max safe |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| nostr.pareto.space | 46.9s | skip | skip | skip | skip | skip | skip | none |
|
||||
| nostr.vps.satsnode.xyz | 51.1s | skip | skip | skip | skip | skip | skip | none |
|
||||
| nostr.vps.satsnode.xyz/… | 51.2s | skip | skip | skip | skip | skip | skip | none |
|
||||
| relay.mostro.network | 60.5s | x@60.8 | skip | skip | skip | skip | skip | none |
|
||||
| nostr.bond/alpha | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none |
|
||||
| nostr.sgiath.dev | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none |
|
||||
| nostr.bitcoinplebs.de | 60.9s | x@61.1 | skip | skip | skip | skip | skip | none |
|
||||
| nostr.schneimi.de | 62.2s | x@61.1 | skip | skip | skip | skip | skip | none |
|
||||
| cfrelay.snowcait.workers.dev | 118.2s | x@85.0 | x@74.5 | skip | skip | skip | skip | none |
|
||||
| nostr-verified.wellorder.net | 120.7s | **OK** | x@120.7 | x@120.8 | skip | skip | skip | 55s |
|
||||
| nostr-pub.wellorder.net | 120.8s | **OK** | x@120.8 | x@120.8 | skip | skip | skip | 55s |
|
||||
| git.shakespeare.diy/… | 125.7s | **OK** | x@125.9 | x@126.1 | skip | skip | skip | 55s |
|
||||
| nostr-relay.irgenius.org | 126.0s | **OK** | x@125.8 | x@125.9 | skip | skip | skip | 55s |
|
||||
| nostr-verif.slothy.win | 126.1s | **OK** | x@126.1 | x@126.3 | skip | skip | skip | 55s |
|
||||
| nostr.bit4use.com | 126.6s | **OK** | x@126.4 | x@126.5 | skip | skip | skip | 55s |
|
||||
| sendit.nosflare.com | 131.4s | x@81.7 | x@41.9 | x@7.0 | skip | skip | skip | none |
|
||||
| **relay.ditto.pub** | 240.9s | OK | OK | **OK** | x@673.5 | **x@609.8** | skip | **120s** |
|
||||
| **david.nostr1.com** | 300.5s | OK | OK | **OK** | x@300.6 | **x@300.7** | x@300.7 | **120s** |
|
||||
| **dkkc.nostr1.com/…** | 300.7s | OK | OK | **OK** | x@300.7 | **x@301.1** | x@300.6 | **120s** |
|
||||
| nos.lol/<haven path> | 600.8s | OK | OK | OK | OK | OK | x@602.1 | 240s |
|
||||
| **relay.snort.social** | 601.0s | OK | OK | OK | OK | OK | **x@607.7** | 240s |
|
||||
|
||||
Key observations:
|
||||
|
||||
1. **A client ping interval numerically below the idle timeout is NOT
|
||||
sufficient.** 180s and 240s pings failed against the ~300s
|
||||
`nostr1.com` tier, and 300s pings failed against the ~600s
|
||||
`snort.social` tier, even though each ping "should" have arrived in
|
||||
time. The empirical rule across every tier: **pings only reliably
|
||||
reset a relay's idle timer when the interval is at most roughly half
|
||||
the timeout.** (Likely cause: these stacks check activity in coarse
|
||||
windows rather than resetting a precise per-frame deadline, so an
|
||||
interval near the window size loses boundary races.)
|
||||
2. The **~60s tier is unsalvageable** — even 55s pings didn't help
|
||||
(their timers count only data frames). These 8 relays drop idle
|
||||
Amethyst connections *today* under the 120s setting and would under
|
||||
any setting; the existing reconnect-on-demand path is the correct
|
||||
handling for them.
|
||||
3. The **~120s tier is only rescued by ≤55s pings** — meaning
|
||||
**today's 120s interval never kept them alive either** (110s and
|
||||
120s pings both failed). They cycle today; they'd cycle at 240s.
|
||||
No candidate change affects them.
|
||||
4. The tiers that DO depend on our ping interval are exactly
|
||||
**ditto (~240s), nostr1.com (~300s), and snort/nos.lol-haven
|
||||
(~600s)** — and 120s holds all of them, while 240s loses the first
|
||||
two and 300s loses all three.
|
||||
|
||||
Connections kept alive (of 122 reachable), by candidate interval:
|
||||
**55s → 110 · 120s → 104 · 240s → 101 · 300s → 99.**
|
||||
|
||||
The `relay.ditto.pub` case study confirms the mechanism: with an idle
|
||||
connection its *own* ping at t=123s received our pong and it still
|
||||
closed at 257s (pongs don't count as activity), but with 60s client
|
||||
pings it stayed up indefinitely (client pings do count).
|
||||
|
||||
## 6. Why not go lower than 120s?
|
||||
|
||||
55s pings would rescue the ~120s tier (6 relays). But:
|
||||
|
||||
- those relays already cycle today, so the status quo loses nothing;
|
||||
- 55s pings double the client-ping traffic on all ~100+ connections to
|
||||
rescue 5% of relays whose operators chose aggressive timeouts;
|
||||
- the radio is already woken every ≤70s on 82/122 connections by server
|
||||
pings, so the *incremental* battery cost is modest — but so is the
|
||||
benefit, and drop/reconnect for those 6 relays is already handled
|
||||
gracefully by `BasicRelayClient`'s backoff + the keep-alive sweep.
|
||||
|
||||
A per-relay adaptive interval (shorten pings only for relays observed to
|
||||
drop idle connections) is possible future work, but OkHttp's
|
||||
`pingInterval` is per-client, not per-socket, so it would require
|
||||
per-relay client instances — not worth the complexity for 6 relays.
|
||||
|
||||
## 7. Carrier NAT — the part this study cannot measure
|
||||
|
||||
The other purpose of client pings is keeping carrier NAT/firewall
|
||||
mappings alive on cellular. That is untestable from a datacenter vantage.
|
||||
Published measurements and platform folklore put aggressive carrier TCP
|
||||
idle timeouts around 4–5 minutes (most are 15–30 min; FCM survives on
|
||||
~28 min heartbeats *with OS cooperation Amethyst doesn't get*). 120s
|
||||
sits comfortably inside even the aggressive bound, so relay-side and
|
||||
NAT-side constraints agree on the same answer. Anyone wanting to raise
|
||||
the interval later must first re-run Phase B *and* validate on real
|
||||
cellular networks — the relay data alone already rules out 240s.
|
||||
|
||||
## 8. Decision
|
||||
|
||||
- **`WEBSOCKET_PING_INTERVAL_SECS = 120`, one value for wifi and mobile.**
|
||||
The tentative 240s mobile value was reverted in this same branch after
|
||||
these measurements: it saved ~nothing (server pings dominate radio
|
||||
wakes) and dropped the ditto and nostr1.com tiers.
|
||||
- OkHttp's `pingInterval` doubles as the dead-connection detector (a
|
||||
missed pong fails the socket within one interval), so 120s also keeps
|
||||
failure detection twice as fast as 240s would — relevant after silent
|
||||
network path changes.
|
||||
|
||||
## 9. Reproduction
|
||||
|
||||
Vantage caveats: datacenter egress IP (18 relays refused it), all
|
||||
traffic via an HTTP CONNECT proxy. A control connection with 100s pings
|
||||
survived every window, ruling out proxy-imposed idle limits ≤ 780s.
|
||||
|
||||
Sketch (Python `websocket-client`): open `wss://` to each relay, send
|
||||
one REQ whose filter matches nothing (`{"kinds":[1],"authors":["00…01"],
|
||||
"limit":1}`), auto-pong server pings, and either never ping (Phase A,
|
||||
780s window) or ping at the candidate interval (Phase B). Log connect /
|
||||
EOSE / server-ping / close timestamps. Population: top-N relays by
|
||||
`r`-tag frequency across kind:10002 events fetched from indexer relays.
|
||||
|
||||
## Appendix — Phase A survivor cadences (99 relays)
|
||||
|
||||
Server-ping cadence measured over the 13-minute window. `none` means the
|
||||
relay sent no pings at all and still held the idle connection.
|
||||
|
||||
| cadence | relays |
|
||||
|---|---|
|
||||
| ~25–35s | `articles.layer3.news`, `aegis.relayted.de`, `assistantrelay.rodbishop.nz`, `bots.utxo.one`, `custom.fiatjaf.com`, `dev.calendar-relay.edufeed.org`, `greensoul.space` (×2), `groups.0xchat.com`, `groups.satsdisco.com`, `h.codingarena.top/inbox`, `haven.calva.dev/inbox`, `haven.nostrfreedom.net`, `haven.relayted.de`, `hist.nostr.land`, `lang.relays.land` (×3), `nexus.libernet.app`, `nip17.com`, `nostr-01.uid.ovh`, `nostr-relay.derekross.me` (×2), `nostr.damupi.com/inbox`, `nostr.easydns.ca`, `nostr.kfx.fr` (×2), `nostr.land`, `nostr.nothing.is-lost.org/haven`, and 17 more at ~30s; `nostrelites.org`, `purplepag.es`, `relay.noswhere.com` at ~30s |
|
||||
| ~55–70s | `nostr.thalheim.io`, `nostr.xmr.rocks`, `offchain.pub`, `relay.mostr.pub`, `relay.nostr.net`, `relay.primal.net`, `relay.damus.io`, `indexer.coracle.social`, `directory.yabu.me`, `user.kindpag.es`, `profiles.nostr1.com`, `nostr.oxtr.dev`, and ~25 more |
|
||||
| ~300s | `nostr-relay.corb.net`, `nostr.001.j5s9.dev`, `nostr.8777.ch`, `nostr.einundzwanzig.space`, `nostr.mikoshi.de`, `nostr.pbfs.io`, `nostr.sectiontwo.org`, `nostr.wild-vibes.ts.net` |
|
||||
| none | `nos.lol`, `nostr.mom`, `relay.divine.video`, `relay.fountain.fm`, `koru.bitcointxoko.org`, `nostr-pr02.redscrypt.org`, 2 × Cloudflare-Workers relays, 1 other |
|
||||
|
||||
Raw JSON for both phases (per-relay timestamps, close codes, server-ping
|
||||
series) was captured during the study session; the tables above are the
|
||||
complete decision-relevant summary.
|
||||
@@ -0,0 +1,178 @@
|
||||
# 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 = <dev pubkey>, 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<Boolean>), 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<String, Long>` per UTC epoch-day, retained ~30 days. Key grammar:
|
||||
`<area>...<mobile|wifi>.<fg|bg>[.<rx|tx>]`, 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)
|
||||
- `relay.connects.<net>.<vis>` / `relay.connfails.<net>.<vis>` — completed
|
||||
(re)connections and failed dials; each connect paid a TCP+TLS handshake,
|
||||
so high daily counts are the reconnect-churn signature
|
||||
- `cpu.ms` — whole-process CPU time deltas ([android.os.Process
|
||||
.getElapsedCpuTime] sampled at flush): the honest aggregate of parsing,
|
||||
crypto, coroutines, and UI without per-subsystem guesswork
|
||||
- `app.fgms` — time with UI visible; display power is proportional to it and
|
||||
it's the denominator for every per-day comparison
|
||||
- `crypto.verify.count` / `crypto.verify.us` — event signature verifications
|
||||
(LocalCache.justVerify hook), settling "does Schnorr verify cost matter"
|
||||
with data
|
||||
- `net.<role>.<net>.<vis>.reqs` / `.activems` — HTTP request counts and
|
||||
active-transfer time per subsystem; counting lives on the shared base
|
||||
client (OkHttpClientFactory) with tag-based role attribution, so untagged
|
||||
callers land in `other` instead of escaping the ledger
|
||||
- `net.bursts.<net>.<vis>` — estimated radio wake-ups from HTTP burst
|
||||
patterns (new activity after >10s of HTTP silence): the battery-relevant
|
||||
measure that bytes alone can't capture, since scattered small requests
|
||||
each pay the radio ramp+tail
|
||||
- `media.playms` — actual media playback time (ExoPlayer isPlaying
|
||||
segments): decoder + screen + streaming at once, the denominator for
|
||||
video bytes
|
||||
|
||||
- `pow.ms` / `pow.sessions` — NIP-13 mining time (any job mining in the
|
||||
PoW queue): full-core CPU, the largest attributable CPU consumer
|
||||
- `tor.ms` / `tor.starts` — in-app (Arti) Tor uptime and bootstraps, from the
|
||||
raw TorService status (NOT TorManager.status, whose WhileSubscribed
|
||||
upstream calls service.start() when collected). External Tor (Orbot) is
|
||||
deliberately untracked — its battery belongs to Orbot
|
||||
- `service.alwayson.ms` — NotificationRelayService uptime: the mode context
|
||||
that explains a device's relay connection-time
|
||||
- `call.ms`/`call.sessions`, `nests.ms`/`nests.sessions` — calls and NIP-53
|
||||
audio rooms (mic + Opus + live media connection), from the foreground
|
||||
services' lifecycles
|
||||
- `location.ms` — time actively listening for GPS updates (geohash tagging);
|
||||
mostly a tripwire for a leaked location subscription
|
||||
- `crypto.decrypt.count/us`, `crypto.encrypt.count/us` — NIP-04/44 work via a
|
||||
MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at
|
||||
account load; durations metered only for local-key signers (external/
|
||||
remote waits are IPC/network, not CPU)
|
||||
- `sign.local|nip46|nip55.count` — signatures by signer kind: NIP-46 is a
|
||||
relay round-trip and NIP-55 an Amber IPC wake, so the kind is the
|
||||
battery-relevant dimension (this supersedes "signing is negligible", which
|
||||
is only true for local keys)
|
||||
- `battery.drain.fg|bg` — measured battery percent while discharging, sampled
|
||||
at flush from BatteryManager: NOT app-isolated, but the ground truth that
|
||||
report corpora can correlate the other counters against
|
||||
|
||||
- `screen.<Name>.ms` — foreground time per screen, added after the original
|
||||
privacy review: only the route's base NAME is recorded (screenNameOf strips
|
||||
every navigation argument before the value leaves the nav layer), so the
|
||||
ledger can say "Profile" but never whose profile
|
||||
|
||||
Deliberately not tracked (v1): per-coroutine or per-dispatcher CPU (needs a
|
||||
thread registry; `cpu.ms` answers whether CPU matters at all first).
|
||||
(Two earlier v1 exclusions were later revisited: per-screen time ships with
|
||||
names-only privacy as above, and signing is now counted per signer kind
|
||||
because NIP-46/NIP-55 signatures are network/IPC round-trips, not local CPU.)
|
||||
|
||||
Flat keys keep the store schema-free: new counters need no migration.
|
||||
|
||||
### 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<String, LongAdder>`
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
@@ -50,6 +51,8 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
@@ -84,8 +87,24 @@ 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.BatteryDrainSampler
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
|
||||
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.ScreenTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor
|
||||
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
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
|
||||
@@ -96,7 +115,9 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorService
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
@@ -104,6 +125,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
|
||||
@@ -123,10 +145,15 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -139,6 +166,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -209,7 +237,7 @@ class AppModules(
|
||||
// App services that should be run as soon as there are subscribers to their flows
|
||||
val locationManager by lazy {
|
||||
Log.d("AppModules", "LocationManager Init")
|
||||
LocationState(appContext, applicationIOScope)
|
||||
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
|
||||
}
|
||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||
|
||||
@@ -218,7 +246,8 @@ class AppModules(
|
||||
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
|
||||
}
|
||||
|
||||
val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope)
|
||||
private val torService = TorService(appContext)
|
||||
val torManager = TorManager(torPrefs, torService, applicationIOScope)
|
||||
|
||||
// Network identity change (wifi↔cellular, regained from offline, captive portal
|
||||
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
|
||||
@@ -273,6 +302,87 @@ 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)
|
||||
|
||||
// Estimates radio wake-ups from HTTP burst patterns — bytes alone don't
|
||||
// predict battery; scattered small requests each pay the radio ramp+tail.
|
||||
private val radioBurstEstimator =
|
||||
RadioBurstEstimator(
|
||||
accountant = resourceUsage,
|
||||
isMobile = { connManager.isMobileOrFalse.value },
|
||||
isForeground = { foregroundTracker.isForeground.value },
|
||||
)
|
||||
|
||||
// Single catch-all counter on the shared non-relay HTTP client: role
|
||||
// wrappers only relabel via request tags, so no HTTP traffic (including
|
||||
// direct getHttpClient users like the napplet broker) escapes the ledger.
|
||||
private val httpUsageInterceptor =
|
||||
UsageCountingInterceptor(
|
||||
accountant = resourceUsage,
|
||||
isMobile = { connManager.isMobileOrFalse.value },
|
||||
isForeground = { foregroundTracker.isForeground.value },
|
||||
bursts = radioBurstEstimator,
|
||||
)
|
||||
|
||||
private val httpUsageMeter = HttpUsageMeter()
|
||||
|
||||
// Session-time counters for the app's long-running battery consumers.
|
||||
// All timer-free segment integrators: services and status flows flip them
|
||||
// on/off, so tracking costs one counter write per transition.
|
||||
val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS, UsageKeys.ALWAYS_ON_STARTS).also { it.registerFlushHook() }
|
||||
val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() }
|
||||
val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() }
|
||||
private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() }
|
||||
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
|
||||
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
|
||||
|
||||
// Time-per-screen (route base names only — arguments never reach the
|
||||
// ledger). Fed by the navigation listener in AppNavigation; foreground
|
||||
// gating means backgrounding on a screen closes its segment.
|
||||
val screenTime = ScreenTimeIntegrator(resourceUsage)
|
||||
|
||||
init {
|
||||
screenTime.start(applicationIOScope, foregroundTracker.isForeground)
|
||||
}
|
||||
|
||||
// In-app (Arti) Tor uptime. Watches the raw TorService status — NOT
|
||||
// TorManager.status, whose upstream is WhileSubscribed and calls
|
||||
// service.start() when collected, so a permanent ledger subscription
|
||||
// there would keep Tor's control flow alive on its own. External Tor
|
||||
// (Orbot) is deliberately untracked: its battery belongs to Orbot.
|
||||
init {
|
||||
applicationIOScope.launch {
|
||||
torService.status
|
||||
.map { it is TorServiceStatus.Active }
|
||||
.distinctUntilChanged()
|
||||
.collect { torSession.setActive(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Measured battery drain (percent while discharging, fg/bg) — the ground
|
||||
// truth the app counters get correlated against. One binder read per
|
||||
// ledger flush, nothing while idle.
|
||||
init {
|
||||
val batteryManager = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
|
||||
if (batteryManager != null) {
|
||||
BatteryDrainSampler(
|
||||
accountant = resourceUsage,
|
||||
capacityPct = { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it in 1..100 } },
|
||||
isCharging = { batteryManager.isCharging },
|
||||
isForeground = { foregroundTracker.isForeground.value },
|
||||
).register()
|
||||
}
|
||||
}
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients: DualHttpClientManager =
|
||||
DualHttpClientManager(
|
||||
@@ -292,10 +402,11 @@ class AppModules(
|
||||
master && !profileOnly && localBlossomCacheProbe.available.value
|
||||
},
|
||||
onionCache = onionLocationCache,
|
||||
usageInterceptor = httpUsageInterceptor,
|
||||
)
|
||||
|
||||
// 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")
|
||||
@@ -599,6 +710,33 @@ class AppModules(
|
||||
// Captures statistics about relays
|
||||
val relayStats = RelayStats(client)
|
||||
|
||||
// Resource-usage ledger: relay traffic/reconnect + connection-time,
|
||||
// foreground-time, process-CPU, and signature-verification 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)
|
||||
ForegroundTimeIntegrator(
|
||||
isForeground = foregroundTracker.isForeground,
|
||||
accountant = resourceUsage,
|
||||
).start(applicationIOScope)
|
||||
ProcessCpuSampler(resourceUsage).register()
|
||||
cache.verifyMeter = { elapsedNanos, _ ->
|
||||
resourceUsage.add(UsageKeys.VERIFY_COUNT, 1)
|
||||
resourceUsage.add(UsageKeys.VERIFY_US, elapsedNanos / 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -635,7 +773,18 @@ class AppModules(
|
||||
minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()),
|
||||
persistence = powJobStore,
|
||||
onQueueActive = { PowMiningForegroundService.start(appContext) },
|
||||
)
|
||||
).also { queue ->
|
||||
// Resource ledger: mining burns half the cores flat-out for as
|
||||
// long as it runs — without this, PoW shows up in cpu.ms as an
|
||||
// unattributed mystery. Wired inside the lazy so the ledger never
|
||||
// forces the queue to initialize.
|
||||
applicationIOScope.launch {
|
||||
queue.jobs
|
||||
.map { jobs -> jobs.any { it.isMining } }
|
||||
.distinctUntilChanged()
|
||||
.collect { powSession.setActive(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val powJobRestorer by lazy {
|
||||
@@ -656,6 +805,7 @@ class AppModules(
|
||||
client = client,
|
||||
rootFilesDir = { appContext.filesDir },
|
||||
powQueue = { powPublishQueue },
|
||||
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
|
||||
)
|
||||
|
||||
val sessionManager =
|
||||
@@ -734,7 +884,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.
|
||||
@@ -807,7 +961,9 @@ class AppModules(
|
||||
diskCache = { diskCache },
|
||||
memoryCache = { memoryCache },
|
||||
blossomServerResolver = { blossomResolver },
|
||||
callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) },
|
||||
// Through the role builder (not raw getHttpClient) so Coil's image
|
||||
// traffic carries the "image" ledger tag. Same Tor decision inside.
|
||||
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
|
||||
thumbnailCache = thumbnailDiskCache,
|
||||
backgroundScope = applicationIOScope,
|
||||
)
|
||||
@@ -818,6 +974,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
|
||||
@@ -886,17 +1046,59 @@ class AppModules(
|
||||
// starts observing LocalCache for notification-worthy events
|
||||
notificationDispatcher.start()
|
||||
|
||||
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
|
||||
// Runs independently of the always-on notification setting so scheduled
|
||||
// posts still fire when always-on notifications are disabled.
|
||||
ScheduledPostWorker.schedule(appContext)
|
||||
ScheduledPostWorker.scheduleCatchUp(appContext)
|
||||
// Keep the scheduled-posts worker (15-min periodic + one-time catch-up)
|
||||
// enqueued exactly while the store holds a PENDING post — see
|
||||
// ScheduledPostWorkGate. Runs independently of the always-on
|
||||
// notification setting so scheduled posts still fire when always-on
|
||||
// notifications are disabled.
|
||||
ScheduledPostWorkGate(
|
||||
store = scheduledPostStore,
|
||||
scope = applicationIOScope,
|
||||
onPendingWork = {
|
||||
ScheduledPostWorker.schedule(appContext)
|
||||
ScheduledPostWorker.scheduleCatchUp(appContext)
|
||||
},
|
||||
onNoPendingWork = { ScheduledPostWorker.cancelPeriodic(appContext) },
|
||||
).start()
|
||||
|
||||
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
|
||||
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
|
||||
// periodic minimum and the lead-time window.
|
||||
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
.schedule(appContext)
|
||||
// "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as
|
||||
// ACCEPTED. The 15-min periodic scanner is only scheduled while it can
|
||||
// plausibly fire: this observer enqueues it when an accepted RSVP lands in
|
||||
// LocalCache, and the worker cancels its own chain when the cache holds
|
||||
// nothing that could still start. LocalCache is memory-only, so the
|
||||
// unconditional schedule this replaces could never fire from a WorkManager
|
||||
// cold start anyway — it only cost battery.
|
||||
applicationIOScope.launch {
|
||||
LocalCache
|
||||
.observeNewEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND)))
|
||||
.collect { rsvp ->
|
||||
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED) {
|
||||
CalendarReminderWorker.schedule(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A rescheduled appointment must also re-arm the chain: the worker
|
||||
// cancels itself when every known target is in the past, and a
|
||||
// kind-31922/31923 update (the organizer moving the event) arrives
|
||||
// WITHOUT any new RSVP — the user's existing RSVP still points at the
|
||||
// same address, and observeNewEvents never re-fires for it. conflate +
|
||||
// delay bounds the cache rescan to one per 30s while event feeds
|
||||
// stream slot events; the scan only runs for users with reminders on.
|
||||
applicationIOScope.launch {
|
||||
LocalCache
|
||||
.observeNewEvents<Event>(
|
||||
Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)),
|
||||
).conflate()
|
||||
.collect {
|
||||
if (CalendarReminderPrefs(appContext).isEnabled() &&
|
||||
CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now())
|
||||
) {
|
||||
CalendarReminderWorker.schedule(appContext)
|
||||
}
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for account login and start/stop always-on notification service
|
||||
applicationIOScope.launch {
|
||||
@@ -979,6 +1181,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()
|
||||
|
||||
@@ -3216,10 +3216,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
live.removedNote(newNote)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-usage ledger hook: called with (elapsedNanos, valid) for every
|
||||
* signature verification so the app can account crypto CPU per day.
|
||||
* Wired by AppModules like [onchainBackend]; null costs nothing.
|
||||
*/
|
||||
@Volatile
|
||||
var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null
|
||||
|
||||
fun justVerify(event: Event): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
return if (!event.verify()) {
|
||||
val meter = verifyMeter
|
||||
if (meter == null) return justVerifyInner(event)
|
||||
|
||||
val start = System.nanoTime()
|
||||
val valid = justVerifyInner(event)
|
||||
meter(System.nanoTime() - start, valid)
|
||||
return valid
|
||||
}
|
||||
|
||||
private fun justVerifyInner(event: Event): Boolean =
|
||||
if (!event.verify()) {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
@@ -3230,7 +3248,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: DraftWrapEvent,
|
||||
|
||||
+3
-1
@@ -64,6 +64,8 @@ class AccountCacheState(
|
||||
val client: INostrClient,
|
||||
val rootFilesDir: () -> File = { File("") },
|
||||
val powQueue: () -> PoWPublishQueue? = { null },
|
||||
/** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */
|
||||
val meterSigner: (NostrSigner) -> NostrSigner = { it },
|
||||
) {
|
||||
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
|
||||
|
||||
@@ -176,7 +178,7 @@ class AccountCacheState(
|
||||
|
||||
val signerWithClientTag =
|
||||
NostrSignerWithClientTag(
|
||||
inner = signer,
|
||||
inner = meterSigner(signer),
|
||||
clientName = CLIENT_TAG_NAME,
|
||||
disabled = { !accountSettings.syncedSettings.security.addClientTag.value },
|
||||
)
|
||||
|
||||
+20
-7
@@ -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
|
||||
|
||||
+60
-7
@@ -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
|
||||
@@ -47,15 +49,26 @@ import java.util.concurrent.TimeUnit
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*
|
||||
* The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP
|
||||
* observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and
|
||||
* [doWork] cancels the chain when the cache holds no accepted RSVP that could still start.
|
||||
* LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty
|
||||
* cache and can never fire a reminder — an unconditional periodic schedule would cold-start
|
||||
* the whole app graph every 15 minutes forever for zero benefit.
|
||||
*/
|
||||
class CalendarReminderWorker(
|
||||
appContext: Context,
|
||||
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; skipping scan." }
|
||||
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
|
||||
// The settings toggle re-schedules on enable; no reason to keep
|
||||
// waking the process while the feature is off.
|
||||
cancel(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
@@ -66,12 +79,7 @@ class CalendarReminderWorker(
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
// present in cache — the alternative (looking only at the foreground account) would
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
val acceptedRsvps = acceptedRsvpsInCache()
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
|
||||
@@ -110,6 +118,22 @@ class CalendarReminderWorker(
|
||||
|
||||
// Prune entries for events that ended more than a day ago — they can't fire again.
|
||||
store.forgetBefore(now - PRUNE_AGE_SECONDS)
|
||||
|
||||
// Nothing left that could ever fire → end the periodic chain instead of
|
||||
// waking the process every 15 minutes forever. The observers in
|
||||
// AppModules re-schedule the worker the next time a live session sees
|
||||
// an accepted RSVP or a calendar-event update.
|
||||
//
|
||||
// Decide on a FRESH cache snapshot, not the one from the start of the
|
||||
// run: an RSVP accepted while this run was scanning already fired the
|
||||
// observer, whose schedule() uses KEEP and no-ops while this chain
|
||||
// still exists — cancelling on the stale snapshot would kill the chain
|
||||
// with that RSVP's reminder permanently lost (observeNewEvents never
|
||||
// re-fires for an event that is already in cache).
|
||||
if (!couldStillFire(acceptedRsvpsInCache(), TimeUtils.now())) {
|
||||
Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." }
|
||||
cancel(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@@ -121,6 +145,35 @@ class CalendarReminderWorker(
|
||||
// more than a day ago; they can't fire again so the entry is pure overhead.
|
||||
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
|
||||
|
||||
/** Every ACCEPTED kind-31925 RSVP currently present in LocalCache. */
|
||||
fun acceptedRsvpsInCache(): List<CalendarRSVPEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
/**
|
||||
* True while at least one accepted RSVP could still produce a reminder:
|
||||
* its target event either starts in the future, or hasn't been fetched
|
||||
* yet (start unknown — the chain must survive until the target
|
||||
* resolves). False means the periodic worker has nothing it could ever
|
||||
* notify about and may cancel its own chain.
|
||||
*/
|
||||
fun couldStillFire(
|
||||
rsvps: Collection<CalendarRSVPEvent>,
|
||||
now: Long,
|
||||
): Boolean =
|
||||
rsvps.any { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@any false
|
||||
val start =
|
||||
LocalCache.addressables
|
||||
.get(targetAddress)
|
||||
?.appointmentView()
|
||||
?.startSeconds
|
||||
start == null || start > now
|
||||
}
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
|
||||
|
||||
+3
@@ -34,6 +34,7 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
@@ -61,6 +62,7 @@ class CallForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.callSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
@@ -151,6 +153,7 @@ class CallForegroundService : Service() {
|
||||
// because CallManager.hangup() transitions to Ended and a second
|
||||
// hangup() from Ended state returns immediately.
|
||||
publishHangupBlocking()
|
||||
Amethyst.instance.callSession.setActive(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.crashreports
|
||||
|
||||
/**
|
||||
* Developer recipient for every user-initiated diagnostic NIP-17 DM — crash
|
||||
* reports and resource-usage reports both route here. Single definition so a
|
||||
* key rotation can never leave one path DMing the old key.
|
||||
*/
|
||||
const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"
|
||||
+1
-1
@@ -80,7 +80,7 @@ fun DisplayCrashMessages(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser("aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"),
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = stack,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
|
||||
@@ -31,13 +31,17 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
class LocationState(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
|
||||
private val onListening: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 10000L
|
||||
@@ -72,6 +76,8 @@ class LocationState(
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
|
||||
}.onEach {
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
|
||||
@@ -106,6 +107,7 @@ class NestForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.nestsSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
wakeLock =
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
@@ -421,6 +423,7 @@ class NestForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Amethyst.instance.nestsSession.setActive(false)
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
wakeLock = null
|
||||
abandonAudioFocus()
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+3
-1
@@ -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() {
|
||||
|
||||
+2
@@ -138,6 +138,7 @@ class NotificationRelayService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
Amethyst.instance.alwaysOnSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
ensureForeground()
|
||||
}
|
||||
@@ -197,6 +198,7 @@ class NotificationRelayService : Service() {
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Service destroyed")
|
||||
Amethyst.instance.alwaysOnSession.setActive(false)
|
||||
relayServiceCollectorJob?.cancel()
|
||||
scope.cancel()
|
||||
|
||||
|
||||
+6
-1
@@ -52,8 +52,13 @@ class ServiceWatchdogManager {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// ELAPSED_REALTIME (not _WAKEUP): a health check is not worth pulling
|
||||
// the CPU out of sleep — if the device is asleep, a restarted service
|
||||
// couldn't do useful network work anyway. The alarm fires as soon as
|
||||
// the device is next awake, and the deeper restart layers (15-min
|
||||
// WorkManager job, FCM/UnifiedPush) cover the force-stop/doze cases.
|
||||
alarmManager.setInexactRepeating(
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
AlarmManager.ELAPSED_REALTIME,
|
||||
SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS,
|
||||
WATCHDOG_INTERVAL_MS,
|
||||
pendingIntent,
|
||||
|
||||
+5
-1
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.Call
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.InetSocketAddress
|
||||
@@ -46,8 +47,11 @@ class DualHttpClientManager(
|
||||
// is uniform across image, upload, NIP-05, money, preview, and push roles.
|
||||
// See [OkHttpClientFactory] kdoc.
|
||||
onionCache: OnionLocationCache,
|
||||
// Resource-usage ledger counter, installed on the shared base client so
|
||||
// every derived client is accounted. See [OkHttpClientFactory].
|
||||
usageInterceptor: Interceptor? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+9
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
@@ -61,6 +62,13 @@ class OkHttpClientFactory(
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
private val onionCache: OnionLocationCache,
|
||||
/**
|
||||
* Resource-usage ledger counter, installed OUTERMOST on the shared base
|
||||
* client so every request through any derived client is accounted —
|
||||
* per-role tagging wrappers only relabel, they never bypass. Null in
|
||||
* tests / pre-configuration call sites.
|
||||
*/
|
||||
private val usageInterceptor: Interceptor? = null,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
@@ -103,6 +111,7 @@ class OkHttpClientFactory(
|
||||
.pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.apply { usageInterceptor?.let { addInterceptor(it) } }
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.apply {
|
||||
blossomCacheRedirect?.let { addInterceptor(it) }
|
||||
|
||||
+13
@@ -40,6 +40,19 @@ class OkHttpClientFactoryForRelays(
|
||||
const val DEFAULT_IS_MOBILE: Boolean = false
|
||||
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
|
||||
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
|
||||
|
||||
// 120s is a measured sweet spot, not a guess — see
|
||||
// amethyst/plans/2026-07-12-relay-ping-interval-study.md (probe of 122
|
||||
// production relays). Idle-timeout tiers observed in production are
|
||||
// ~60s / ~120s / ~240s / ~300s / ~600s, and a client ping only reliably
|
||||
// resets a relay's idle timer when the interval sits well BELOW the
|
||||
// tier (240s pings already lose the ~240s and ~300s tiers, incl. every
|
||||
// nostr1.com-hosted relay; 300s pings lose relay.snort.social). Raising
|
||||
// the interval also saves almost no battery: 90% of surveyed relays
|
||||
// send their own server pings every 30-70s, which OkHttp must answer,
|
||||
// so the radio's wake cadence is set by the relays, not by this value.
|
||||
// Lowering it would only rescue the ~120s tier (6/122 relays, already
|
||||
// cycling today) at 2x the ping traffic on every other connection.
|
||||
const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -86,6 +86,7 @@ class ExoPlayerBuilder(
|
||||
addListener(AutoReplayLimiter(pause = ::pause))
|
||||
addListener(KeepVideosPlaying(this))
|
||||
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
|
||||
addListener(MediaPlayTimeTracker())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.playback.playerPool
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
|
||||
/**
|
||||
* Accounts actual media playback time into the resource-usage ledger.
|
||||
* Playback is one of the most energy-dense things the app does — decoder,
|
||||
* screen, and streaming all at once — and this turns "video used 800 MB"
|
||||
* into "800 MB over 2h of playback" (normal) vs "over 10 minutes" (a bug).
|
||||
*
|
||||
* Attached once per player in [ExoPlayerBuilder]; a player released
|
||||
* mid-playback loses at most its final open segment.
|
||||
*/
|
||||
class MediaPlayTimeTracker(
|
||||
private val onPlayed: (elapsedMs: Long) -> Unit = { ms ->
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.MEDIA_PLAY_MS, ms) }
|
||||
},
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : Player.Listener {
|
||||
private var playingSinceMs = Long.MIN_VALUE
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
val now = nowMs()
|
||||
if (isPlaying) {
|
||||
playingSinceMs = now
|
||||
} else if (playingSinceMs != Long.MIN_VALUE) {
|
||||
onPlayed(now - playingSinceMs)
|
||||
playingSinceMs = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Samples the device battery level at each ledger flush and accumulates the
|
||||
* drops that happen while discharging into [UsageKeys.BATTERY_DRAIN_FG] /
|
||||
* [UsageKeys.BATTERY_DRAIN_BG] (percent points, attributed by visibility at
|
||||
* sample time). This is deliberately NOT app-isolated — it's the measured
|
||||
* ground truth that lets the developers correlate the app's own counters
|
||||
* against real drain across many reports, which is how the alert thresholds
|
||||
* get tuned.
|
||||
*
|
||||
* Intervals touching a charging state (at either endpoint) are skipped, and
|
||||
* a level that went UP just resets the baseline. Piggybacks on the pre-flush
|
||||
* hook — one BatteryManager binder read per flush, nothing while idle.
|
||||
*/
|
||||
class BatteryDrainSampler(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val capacityPct: () -> Int?,
|
||||
private val isCharging: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
) {
|
||||
private var lastPct: Int? = null
|
||||
private var lastCharging = true
|
||||
|
||||
fun register() {
|
||||
accountant.addPreFlushHook(::sample)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sample() {
|
||||
val pct = capacityPct() ?: return
|
||||
val charging = isCharging()
|
||||
val prevPct = lastPct
|
||||
val prevCharging = lastCharging
|
||||
lastPct = pct
|
||||
lastCharging = charging
|
||||
|
||||
if (prevPct == null || prevCharging || charging) return
|
||||
val drop = prevPct - pct
|
||||
if (drop <= 0) return
|
||||
accountant.add(
|
||||
if (isForeground()) UsageKeys.BATTERY_DRAIN_FG else UsageKeys.BATTERY_DRAIN_BG,
|
||||
drop.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.collectMemorySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY
|
||||
import com.vitorpamplona.amethyst.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
|
||||
|
||||
/**
|
||||
* 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 context = LocalContext.current
|
||||
val alert = remember { mutableStateOf<ResourceUsageAlerts.Alert?>(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) {
|
||||
alert.value = found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 7-day rate limit is consumed when the user ACTS on the dialog (any
|
||||
// button or dismissal), not when it is shown: marking before the first
|
||||
// frame let a config change or process death burn the whole prompt budget
|
||||
// on a dialog nobody ever saw, silencing an active battery problem for a
|
||||
// week. Re-showing after an unhandled recreation is exactly what we want.
|
||||
val dismiss = {
|
||||
accountViewModel.runOnIO {
|
||||
Amethyst.instance.resourceUsageStore.markAlertPrompted(TimeUtils.now())
|
||||
}
|
||||
alert.value = null
|
||||
}
|
||||
|
||||
alert.value?.let { found ->
|
||||
AlertDialog(
|
||||
onDismissRequest = dismiss,
|
||||
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)
|
||||
}
|
||||
dismiss()
|
||||
}) {
|
||||
Text(stringRes(R.string.resource_usage_alert_opt_out))
|
||||
}
|
||||
TextButton(onClick = dismiss) {
|
||||
Text(stringRes(R.string.resource_usage_alert_not_now))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
nav.nav {
|
||||
val report =
|
||||
withContext(Dispatchers.IO) {
|
||||
ResourceUsageReportAssembler().buildReport(
|
||||
Amethyst.instance.resourceUsage.allDaysIncludingLive(),
|
||||
Amethyst.instance.resourceUsage.today(),
|
||||
collectMemorySnapshot(context),
|
||||
)
|
||||
}
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = report,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}) {
|
||||
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(),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.RECONNECT_CHURN ->
|
||||
pluralStringResource(
|
||||
R.plurals.resource_usage_reason_reconnects,
|
||||
alert.value.toInt(),
|
||||
alert.value.toInt(),
|
||||
)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Integrates time-with-UI-visible into the ledger ([UsageKeys.APP_FG_MS]).
|
||||
* Screen-on time is what display power is proportional to, and it's the
|
||||
* denominator that makes every other counter interpretable (MB per hour in
|
||||
* app vs MB while backgrounded).
|
||||
*/
|
||||
class ForegroundTimeIntegrator(
|
||||
private val isForeground: Flow<Boolean>,
|
||||
accountant: ResourceUsageAccountant,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<Unit>(accountant, nowMs) {
|
||||
fun start(scope: CoroutineScope): Job {
|
||||
registerFlushHook()
|
||||
return scope.launch {
|
||||
isForeground.collect { fg -> transitionTo(if (fg) Unit else null) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: Unit,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (elapsedMs > 0) accountant.add(UsageKeys.APP_FG_MS, elapsedMs)
|
||||
}
|
||||
}
|
||||
+69
@@ -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<Boolean> = _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) {}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
|
||||
/**
|
||||
* A [NostrSigner] decorator (same pattern as [NostrSignerWithClientTag]) that
|
||||
* accounts signing and encryption work into the resource-usage ledger:
|
||||
*
|
||||
* - signature counts by signer kind — a local-key signature is ~free, a
|
||||
* NIP-55 one wakes the Amber process over IPC, and a NIP-46 one is a full
|
||||
* relay round-trip, so the *kind* is the battery-relevant dimension;
|
||||
* - NIP-04/44 decrypt/encrypt counts, with CPU time metered only when the
|
||||
* wrapped signer is a local key ([NostrSignerInternal]) — for external and
|
||||
* remote signers the elapsed time would be IPC/network wait, not crypto
|
||||
* cost, and would poison the CPU numbers.
|
||||
*
|
||||
* Overhead per operation: one counter increment (plus two [System.nanoTime]
|
||||
* calls for local-key crypto, nanoseconds against a millisecond-scale ECDH) —
|
||||
* nothing here can slow the operations being measured.
|
||||
*
|
||||
* Wrapped INSIDE [NostrSignerWithClientTag] (metering the raw signer), so the
|
||||
* existing `signer is NostrSignerWithClientTag` call sites keep working;
|
||||
* anything that needs the raw signer should unwrap via [innermostSigner].
|
||||
*/
|
||||
class MeteringNostrSigner(
|
||||
val inner: NostrSigner,
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
) : NostrSigner(inner.pubKey) {
|
||||
private val signKey = UsageKeys.signs(signerKind(inner))
|
||||
private val meterCryptoTime = inner is NostrSignerInternal
|
||||
|
||||
override fun isWriteable(): Boolean = inner.isWriteable()
|
||||
|
||||
override suspend fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T {
|
||||
accountant.add(signKey, 1)
|
||||
return inner.sign(createdAt, kind, tags, content)
|
||||
}
|
||||
|
||||
override suspend fun nip04Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip04Encrypt(plaintext, toPublicKey) }
|
||||
|
||||
override suspend fun nip04Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip04Decrypt(ciphertext, fromPublicKey) }
|
||||
|
||||
override suspend fun nip44Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip44Encrypt(plaintext, toPublicKey) }
|
||||
|
||||
override suspend fun nip44Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip44Decrypt(ciphertext, fromPublicKey) }
|
||||
|
||||
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.decryptZapEvent(event) }
|
||||
|
||||
override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce)
|
||||
|
||||
override suspend fun signPsbt(psbtHex: String): String {
|
||||
accountant.add(signKey, 1)
|
||||
return inner.signPsbt(psbtHex)
|
||||
}
|
||||
|
||||
override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport()
|
||||
|
||||
private inline fun <T> metered(
|
||||
countKey: String,
|
||||
usKey: String,
|
||||
op: () -> T,
|
||||
): T {
|
||||
accountant.add(countKey, 1)
|
||||
if (!meterCryptoTime) return op()
|
||||
val start = System.nanoTime()
|
||||
try {
|
||||
return op()
|
||||
} finally {
|
||||
accountant.add(usKey, (System.nanoTime() - start) / 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun signerKind(signer: NostrSigner): String =
|
||||
when (signer) {
|
||||
is NostrSignerExternal -> UsageKeys.SIGNER_NIP55
|
||||
is NostrSignerRemote -> UsageKeys.SIGNER_NIP46
|
||||
else -> UsageKeys.SIGNER_LOCAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the app's signer decorators (client tag, metering) to reach the
|
||||
* concrete signer — for call sites that need the real type, like the NIP-55
|
||||
* activity-launcher registration.
|
||||
*/
|
||||
fun NostrSigner.innermostSigner(): NostrSigner =
|
||||
when (this) {
|
||||
is NostrSignerWithClientTag -> inner.innermostSigner()
|
||||
is MeteringNostrSigner -> inner.innermostSigner()
|
||||
else -> this
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.Process
|
||||
|
||||
/**
|
||||
* Samples whole-process CPU time (user+system, [Process.getElapsedCpuTime])
|
||||
* into the ledger as day deltas. This is the honest aggregate cost of
|
||||
* everything that runs on the CPU — event parsing, signature verification,
|
||||
* coroutines, recomposition — without per-subsystem guesswork. Sampled from
|
||||
* the accountant's pre-flush hook, so it costs one syscall per flush and
|
||||
* nothing while idle. Per-process monotonic: a fresh process simply starts a
|
||||
* fresh baseline.
|
||||
*/
|
||||
class ProcessCpuSampler(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val cpuMs: () -> Long = { Process.getElapsedCpuTime() },
|
||||
) {
|
||||
private var lastSampleMs = cpuMs()
|
||||
|
||||
fun register() {
|
||||
accountant.addPreFlushHook(::sample)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sample() {
|
||||
val now = cpuMs()
|
||||
val delta = now - lastSampleMs
|
||||
lastSampleMs = now
|
||||
if (delta > 0) accountant.add(UsageKeys.CPU_MS, delta)
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.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.
|
||||
*/
|
||||
class RelayConnectionTimeIntegrator(
|
||||
private val connectedCount: Flow<Int>,
|
||||
private val isMobile: Flow<Boolean?>,
|
||||
private val isForeground: Flow<Boolean>,
|
||||
accountant: ResourceUsageAccountant,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<RelayConnectionTimeIntegrator.SegmentState>(accountant, nowMs) {
|
||||
data class SegmentState(
|
||||
val count: Int,
|
||||
val mobile: Boolean,
|
||||
val foreground: Boolean,
|
||||
)
|
||||
|
||||
fun start(scope: CoroutineScope): Job {
|
||||
registerFlushHook()
|
||||
return scope.launch {
|
||||
combine(connectedCount, isMobile, isForeground) { count, mobile, fg ->
|
||||
SegmentState(count, mobile ?: false, fg)
|
||||
}.collect { next -> transitionTo(next) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: SegmentState,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (state.count <= 0 || elapsedMs <= 0) return
|
||||
accountant.add(UsageKeys.relayConnMs(state.mobile, state.foreground), state.count * elapsedMs)
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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())
|
||||
}
|
||||
|
||||
// Every completed (re)connection paid a TCP+TLS handshake; high daily
|
||||
// counts are the signature of reconnect churn (flaky network, aggressive
|
||||
// relay idle timeouts, Tor bootstrap loops).
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1)
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.AtomicLong
|
||||
|
||||
/**
|
||||
* 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 AtomicLongs, drained into
|
||||
* [ResourceUsageStore] by a debounced flush (at most one write per
|
||||
* [flushDebounceMs] while traffic flows; nothing scheduled when idle).
|
||||
*
|
||||
* AtomicLong (not LongAdder) because draining must be loss-free: getAndSet(0)
|
||||
* hands off the accumulated value atomically, whereas remove+sum on a
|
||||
* LongAdder can strand a racing increment on an orphaned cell. Entries stay
|
||||
* in the map after a drain — the key space is small and fixed (dims x areas),
|
||||
* so this costs a few hundred boxed zeros at most.
|
||||
*
|
||||
* Counters added from inside a pre-flush hook (the CPU sampler, the segment
|
||||
* integrators closing an open segment) never re-arm the debounce: they are
|
||||
* drained by the very flush that invoked the hook, and letting them schedule
|
||||
* would turn every flush into a perpetual 30s wake-and-write loop — the
|
||||
* battery ledger becoming its own battery drain.
|
||||
*
|
||||
* 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<String, AtomicLong>()
|
||||
private val flushScheduled = AtomicBoolean(false)
|
||||
|
||||
/** True only on the thread currently running the pre-flush hooks. */
|
||||
private val inHookRun = ThreadLocal.withInitial { 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) { AtomicLong() }.addAndGet(amount)
|
||||
if (inHookRun.get()) return
|
||||
if (flushScheduled.compareAndSet(false, true)) {
|
||||
scope.launch {
|
||||
delay(flushDebounceMs)
|
||||
flushScheduled.set(false)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPreFlushHooks() {
|
||||
inHookRun.set(true)
|
||||
try {
|
||||
preFlushHooks.forEach { runCatching { it() } }
|
||||
} finally {
|
||||
inHookRun.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drains the in-memory counters into today's persisted bucket. */
|
||||
suspend fun flush() {
|
||||
runPreFlushHooks()
|
||||
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. Reading never schedules a flush.
|
||||
*/
|
||||
suspend fun allDaysIncludingLive(): Map<Long, Map<String, Long>> {
|
||||
runPreFlushHooks()
|
||||
val persisted = store.allDays()
|
||||
val liveSnapshot = live.mapValues { it.value.get() }.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<String, Long> {
|
||||
if (live.isEmpty()) return emptyMap()
|
||||
val deltas = mutableMapOf<String, Long>()
|
||||
for ((key, counter) in live) {
|
||||
val value = counter.getAndSet(0L)
|
||||
if (value > 0) deltas[key] = value
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DAY_MS = 24L * 60L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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,
|
||||
RECONNECT_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
|
||||
|
||||
/**
|
||||
* > 5000 completed relay (re)connections in one day. A healthy day is a
|
||||
* few hundred to ~2000 even with a large relay set; sustained thousands
|
||||
* means something is cycling (a stuck relay tier, a flapping network, a
|
||||
* Tor bootstrap loop) and every cycle pays a TLS handshake.
|
||||
*/
|
||||
const val RELAY_CONNECTS_PER_DAY = 5_000L
|
||||
|
||||
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<Long, Map<String, Long>>,
|
||||
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)
|
||||
}
|
||||
if (summary.relayConnects > RELAY_CONNECTS_PER_DAY) {
|
||||
return Alert(Reason.RECONNECT_CHURN, day, summary.relayConnects)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun shouldPrompt(
|
||||
lastAlertAtSec: Long,
|
||||
optOut: Boolean,
|
||||
nowSec: Long,
|
||||
): Boolean = !optOut && nowSec - lastAlertAtSec >= MIN_DAYS_BETWEEN_PROMPTS * 24L * 60L * 60L
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.amethyst.MemorySnapshot
|
||||
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<Long, Map<String, Long>>,
|
||||
today: Long,
|
||||
memory: MemorySnapshot? = null,
|
||||
): 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)))
|
||||
|
||||
if (memory != null) {
|
||||
sb.append("\n**Memory right now**\n\n")
|
||||
sb.append("| Metric | Value |\n")
|
||||
sb.append("| --- | --- |\n")
|
||||
sb.append("| Device class | ${memory.memoryClassMb} MB |\n")
|
||||
sb.append("| App heap | ${memory.heapUsedMb} / ${memory.heapMaxMb} MB |\n")
|
||||
sb.append("| Native heap | ${memory.nativeHeapUsedMb} MB |\n")
|
||||
sb.append("| Image cache (RAM) | ${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB |\n")
|
||||
sb.append("| Image cache (disk) | ${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB |\n")
|
||||
sb.append("| Cached notes/users/addressables/chatrooms | ${memory.noteCount}/${memory.userCount}/${memory.addressableCount}/${memory.chatroomCount} |\n")
|
||||
}
|
||||
|
||||
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("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n")
|
||||
append("| Web requests | ${s.httpRequests} (${s.radioBursts} radio bursts, ${formatDurationMs(s.httpActiveMs)} active) |\n")
|
||||
append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n")
|
||||
append("| PoW mining | ${formatDurationMs(s.powMs)} |\n")
|
||||
append("| Tor uptime (in-app) | ${formatDurationMs(s.torMs)} |\n")
|
||||
append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} (${s.alwaysOnStarts} starts) |\n")
|
||||
append("| Calls / audio rooms | ${formatDurationMs(s.callMs)} / ${formatDurationMs(s.nestsMs)} |\n")
|
||||
append("| Location listening | ${formatDurationMs(s.locationMs)} |\n")
|
||||
append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n")
|
||||
append("| Decryptions | ${s.decryptCount} (${formatDurationMs(s.decryptUs / 1_000)} CPU) |\n")
|
||||
append("| Remote signatures | ${s.signNip46} NIP-46, ${s.signNip55} NIP-55 |\n")
|
||||
append("| Battery drain (measured, whole device) | ${s.batteryDrainFg}% in app, ${s.batteryDrainBg}% background |\n")
|
||||
append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n")
|
||||
append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\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")
|
||||
}
|
||||
val screens =
|
||||
s.screenTimeMs.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(8)
|
||||
.joinToString(", ") { "${it.key} ${formatDurationMs(it.value)}" }
|
||||
if (screens.isNotEmpty()) {
|
||||
append("| Screen time | $screens |\n")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -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<String, Map<String, Long>> = 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<String, Long>,
|
||||
) {
|
||||
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<Long, Map<String, Long>> =
|
||||
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<UsageFile>(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"
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Integrates time-per-screen into the ledger (`screen.<Name>.ms`): which
|
||||
* parts of the app the display/CPU time actually goes to, so a report can
|
||||
* distinguish "8 h of video" from "8 h of feeds".
|
||||
*
|
||||
* PRIVACY: only the route's base name is recorded ([screenNameOf] strips
|
||||
* navigation arguments before anything reaches the ledger) — "Profile" is
|
||||
* tracked, whose profile never is. Time only accrues while the app is in the
|
||||
* foreground: the current-route × foreground combination is the segment
|
||||
* state, so backgrounding on a screen closes its segment.
|
||||
*/
|
||||
class ScreenTimeIntegrator(
|
||||
accountant: ResourceUsageAccountant,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<String>(accountant, nowMs) {
|
||||
private val currentScreen = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Called from the navigation listener with an already-sanitized name (or null when unknown). */
|
||||
fun onScreen(name: String?) {
|
||||
currentScreen.value = name
|
||||
}
|
||||
|
||||
fun start(
|
||||
scope: CoroutineScope,
|
||||
isForeground: Flow<Boolean>,
|
||||
): Job {
|
||||
registerFlushHook()
|
||||
return scope.launch {
|
||||
combine(currentScreen, isForeground) { screen, fg -> if (fg) screen else null }
|
||||
.collect { transitionTo(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: String,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (elapsedMs > 0) accountant.add(UsageKeys.screenMs(state), elapsedMs)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Reduces a Navigation Compose route pattern to its bare screen name:
|
||||
* `com...routes.Route.Profile/{userId}?tab={tab}` becomes `Profile`.
|
||||
* Everything after `/` or `?` — where the arguments live — is dropped
|
||||
* BEFORE the value leaves the navigation layer.
|
||||
*/
|
||||
fun screenNameOf(route: String?): String? =
|
||||
route
|
||||
?.substringBefore('/')
|
||||
?.substringBefore('?')
|
||||
?.substringAfterLast('.')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
/**
|
||||
* Timer-free duration integrator: the elapsed time of the current state is
|
||||
* exact between transitions, so a segment is accounted only when the state
|
||||
* changes — plus on the accountant's pre-flush hook, so multi-hour stable
|
||||
* segments (a call, a backgrounded night with relays connected) still land in
|
||||
* the right day bucket without any periodic timer.
|
||||
*
|
||||
* Durations are measured with [SystemClock.elapsedRealtime] — the monotonic
|
||||
* clock — never the wall clock: an NTP/timezone correction mid-segment must
|
||||
* not fabricate or delete accounted time. (Wall time is only ever used for
|
||||
* choosing the epoch-day bucket, which happens in the accountant.)
|
||||
*
|
||||
* A `null` state means "nothing to account" (idle); subclasses decide what a
|
||||
* non-null state costs per elapsed millisecond.
|
||||
*/
|
||||
abstract class TimeSegmentIntegrator<S : Any>(
|
||||
protected val accountant: ResourceUsageAccountant,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) {
|
||||
private val lock = Any()
|
||||
private var current: S? = null
|
||||
private var segmentStartMs = 0L
|
||||
|
||||
/** Registers [closeOpenSegment] so flushes and reads see up-to-date totals. */
|
||||
fun registerFlushHook() {
|
||||
accountant.addPreFlushHook(::closeOpenSegment)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts the previous segment and starts a new one. Returns the previous
|
||||
* state so callers can detect activations (null -> non-null).
|
||||
*/
|
||||
fun transitionTo(next: S?): S? =
|
||||
synchronized(lock) {
|
||||
val now = nowMs()
|
||||
val prev = current
|
||||
prev?.let { account(it, now - segmentStartMs) }
|
||||
current = next
|
||||
segmentStartMs = now
|
||||
prev
|
||||
}
|
||||
|
||||
protected abstract fun account(
|
||||
state: S,
|
||||
elapsedMs: Long,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The simplest integrator: total time a boolean condition is active (Tor
|
||||
* running, a call in progress, GPS listening), written to [msKey] — plus an
|
||||
* optional [startsKey] counting activations, since starts are often the
|
||||
* expensive part (a Tor bootstrap, a service cold start).
|
||||
*/
|
||||
class SessionTimeIntegrator(
|
||||
accountant: ResourceUsageAccountant,
|
||||
private val msKey: String,
|
||||
private val startsKey: String? = null,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<Unit>(accountant, nowMs) {
|
||||
fun setActive(active: Boolean) {
|
||||
val prev = transitionTo(if (active) Unit else null)
|
||||
if (active && prev == null && startsKey != null) accountant.add(startsKey, 1)
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: Unit,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (elapsedMs > 0) accountant.add(msKey, elapsedMs)
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import 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
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** Request tag carrying the ledger subsystem a request belongs to. */
|
||||
class UsageRoleTag(
|
||||
val role: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Estimates cellular-radio wake-ups caused by HTTP traffic: a new burst is
|
||||
* counted whenever bytes flow after more than [BURST_GAP_MS] of HTTP silence,
|
||||
* approximating the radio having dropped to idle in between (LTE/5G inactivity
|
||||
* timers are typically ~10s). Bytes alone don't predict battery — many small
|
||||
* scattered requests cost far more than one continuous download of the same
|
||||
* size, because every burst pays the radio's ramp + tail energy. This counter
|
||||
* is what makes that pattern visible.
|
||||
*
|
||||
* Caveat: bursts are counted from HTTP activity only. While relays are
|
||||
* connected their traffic keeps the radio awake anyway — that cost is already
|
||||
* captured by the relay connection-time counters.
|
||||
*/
|
||||
class RadioBurstEstimator(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) {
|
||||
@Volatile private var lastActivityMs = Long.MIN_VALUE
|
||||
|
||||
fun onHttpActivity() {
|
||||
val now = nowMs()
|
||||
val last = lastActivityMs
|
||||
lastActivityMs = now
|
||||
if (last == Long.MIN_VALUE || now - last > BURST_GAP_MS) {
|
||||
accountant.add(UsageKeys.radioBursts(isMobile(), isForeground()), 1)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BURST_GAP_MS = 10_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application interceptor that accounts every HTTP request into the ledger:
|
||||
* bytes up/down, request count, and active-transfer time, attributed to the
|
||||
* subsystem named by the request's [UsageRoleTag] (set by the per-role
|
||||
* clients from RoleBasedHttpClientBuilder) or to [defaultRole] for anything
|
||||
* that reaches the shared clients untagged — so no HTTP traffic can escape
|
||||
* the ledger.
|
||||
*
|
||||
* Installed FIRST on the base client (outermost), so the tagging interceptor
|
||||
* of role-wrapped clients must be inserted BEFORE it (see [HttpUsageMeter]).
|
||||
* Download bytes are counted as the app actually consumes the streamed body,
|
||||
* so cancelled loads count only what crossed to the app. Network/visibility
|
||||
* dims are sampled when bytes flow, matching what the radio actually did.
|
||||
*/
|
||||
class UsageCountingInterceptor(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val bursts: RadioBurstEstimator? = null,
|
||||
private val defaultRole: String = UsageKeys.ROLE_OTHER,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
// Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache
|
||||
// hits to 127.0.0.1) never touches the radio: counting it would inflate
|
||||
// the network numbers — and could trip the background-data alert — for
|
||||
// exactly the users who cache aggressively to SAVE data.
|
||||
if (isLoopback(request.url.host)) return chain.proceed(request)
|
||||
|
||||
val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole
|
||||
|
||||
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
|
||||
bursts?.onHttpActivity()
|
||||
|
||||
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
|
||||
if (requestBytes > 0) {
|
||||
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
|
||||
}
|
||||
|
||||
val startedAtMs = nowMs()
|
||||
val response = chain.proceed(request)
|
||||
return response
|
||||
.newBuilder()
|
||||
.body(
|
||||
CountingResponseBody(
|
||||
delegate = response.body,
|
||||
onBytes = { bytes ->
|
||||
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes)
|
||||
bursts?.onHttpActivity()
|
||||
},
|
||||
onFinished = {
|
||||
accountant.add(UsageKeys.netActiveMs(role, isMobile(), isForeground()), nowMs() - startedAtMs)
|
||||
},
|
||||
),
|
||||
).build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */
|
||||
fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]"
|
||||
}
|
||||
|
||||
private class CountingResponseBody(
|
||||
private val delegate: ResponseBody,
|
||||
private val onBytes: (Long) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
) : ResponseBody() {
|
||||
private val finished = AtomicBoolean(false)
|
||||
private val onFinishedOnce = {
|
||||
if (finished.compareAndSet(false, true)) onFinished()
|
||||
}
|
||||
|
||||
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)
|
||||
} else if (read == -1L) {
|
||||
onFinishedOnce()
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
onFinishedOnce()
|
||||
}
|
||||
}.buffer()
|
||||
}
|
||||
|
||||
override fun source() = countedSource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands out per-subsystem OkHttp clients: the shared base client (which
|
||||
* carries the single [UsageCountingInterceptor]) wrapped with a tagging
|
||||
* interceptor inserted at position 0, OUTSIDE the counter, so the tag is
|
||||
* visible when the counter reads it. Wrapped clients are cached per
|
||||
* (role, base identity); base clients are rebuilt on proxy/network changes,
|
||||
* so the cache is cleared when it grows past a small bound.
|
||||
*/
|
||||
class HttpUsageMeter {
|
||||
private val wrapped = ConcurrentHashMap<Pair<String, OkHttpClient>, OkHttpClient>()
|
||||
|
||||
fun counted(
|
||||
role: String,
|
||||
base: OkHttpClient,
|
||||
): OkHttpClient {
|
||||
if (wrapped.size > MAX_CACHED) wrapped.clear()
|
||||
return wrapped.getOrPut(role to base) {
|
||||
base
|
||||
.newBuilder()
|
||||
.apply { interceptors().add(0, RoleTaggingInterceptor(role)) }
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private class RoleTaggingInterceptor(
|
||||
private val role: String,
|
||||
) : Interceptor {
|
||||
private val tag = UsageRoleTag(role)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response =
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.tag(UsageRoleTag::class.java, tag)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// roles (8) 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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"
|
||||
|
||||
/** Catch-all for HTTP requests that reach the shared clients without a role tag. */
|
||||
const val ROLE_OTHER = "other"
|
||||
|
||||
val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER)
|
||||
|
||||
/** `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}"
|
||||
|
||||
/** `net.image.mobile.bg.reqs` — HTTP request count for a subsystem. */
|
||||
fun netReqs(
|
||||
role: String,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "net.$role.${dim(mobile, foreground)}.reqs"
|
||||
|
||||
/** `net.image.mobile.bg.activems` — wall time spent actively transferring. */
|
||||
fun netActiveMs(
|
||||
role: String,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "net.$role.${dim(mobile, foreground)}.activems"
|
||||
|
||||
/** `net.bursts.mobile.bg` — estimated radio wake-ups caused by HTTP traffic. */
|
||||
fun radioBursts(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "net.bursts.${dim(mobile, foreground)}"
|
||||
|
||||
/** `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)}"
|
||||
|
||||
/** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */
|
||||
fun relayConnects(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connects.${dim(mobile, foreground)}"
|
||||
|
||||
/** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */
|
||||
fun relayConnectFails(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connfails.${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"
|
||||
|
||||
/** Whole-process CPU time (user+system) — the honest aggregate of parsing, crypto, coroutines, and UI. */
|
||||
const val CPU_MS = "cpu.ms"
|
||||
|
||||
/** Time with at least one activity STARTED — the denominator that makes the other counters interpretable. */
|
||||
const val APP_FG_MS = "app.fgms"
|
||||
|
||||
/** Event signature verifications (LocalCache.justVerify). */
|
||||
const val VERIFY_COUNT = "crypto.verify.count"
|
||||
const val VERIFY_US = "crypto.verify.us"
|
||||
|
||||
/** Media (video/audio) playback time — decoder + screen + streaming all at once. */
|
||||
const val MEDIA_PLAY_MS = "media.playms"
|
||||
|
||||
/** NIP-13 proof-of-work mining: full-core CPU for as long as it runs. */
|
||||
const val POW_MS = "pow.ms"
|
||||
const val POW_SESSIONS = "pow.sessions"
|
||||
|
||||
/** In-app (Arti) Tor: circuit crypto + directory/guard keep-alives while up; each start pays a bootstrap. */
|
||||
const val TOR_MS = "tor.ms"
|
||||
const val TOR_STARTS = "tor.starts"
|
||||
|
||||
/**
|
||||
* Always-on notification relay service: uptime (the mode context for its
|
||||
* relay connections) and starts — each start beyond the first is churn
|
||||
* (watchdog alarm or auto-restart re-launching a killed service).
|
||||
*/
|
||||
const val ALWAYS_ON_MS = "service.alwayson.ms"
|
||||
const val ALWAYS_ON_STARTS = "service.alwayson.starts"
|
||||
|
||||
/** Calls and NIP-53 audio rooms: mic + Opus + a live media connection. */
|
||||
const val CALL_MS = "call.ms"
|
||||
const val CALL_SESSIONS = "call.sessions"
|
||||
const val NESTS_MS = "nests.ms"
|
||||
const val NESTS_SESSIONS = "nests.sessions"
|
||||
|
||||
/** Time spent actively listening for GPS/location updates (geohash tagging). */
|
||||
const val LOCATION_MS = "location.ms"
|
||||
|
||||
/**
|
||||
* `screen.Home.ms` — time a screen was visible while the app was in the
|
||||
* foreground. PRIVACY: only the route's base NAME is ever recorded, never
|
||||
* its navigation arguments — "Profile" is tracked, whose profile is not
|
||||
* (see ScreenTimeIntegrator.screenNameOf, which strips them).
|
||||
*/
|
||||
fun screenMs(screen: String): String = "$SCREEN_PREFIX$screen.ms"
|
||||
|
||||
const val SCREEN_PREFIX = "screen."
|
||||
|
||||
/**
|
||||
* NIP-04/44 decryptions and encryptions through account signers. Durations
|
||||
* are only metered for local-key signers (CPU cost); external/remote
|
||||
* signer waits are IPC/network, tracked by the sign/decrypt counts alone.
|
||||
*/
|
||||
const val DECRYPT_COUNT = "crypto.decrypt.count"
|
||||
const val DECRYPT_US = "crypto.decrypt.us"
|
||||
const val ENCRYPT_COUNT = "crypto.encrypt.count"
|
||||
const val ENCRYPT_US = "crypto.encrypt.us"
|
||||
|
||||
/** `sign.nip46.count` — signatures by signer kind: local key, NIP-55 (Amber IPC), NIP-46 (relay round-trip). */
|
||||
fun signs(kind: String): String = "sign.$kind.count"
|
||||
|
||||
const val SIGNER_LOCAL = "local"
|
||||
const val SIGNER_NIP46 = "nip46"
|
||||
const val SIGNER_NIP55 = "nip55"
|
||||
|
||||
/**
|
||||
* Measured battery drain (percent points while discharging), split by
|
||||
* visibility. Not app-isolated — it's the ground truth the other counters
|
||||
* get correlated against across reports.
|
||||
*/
|
||||
const val BATTERY_DRAIN_FG = "battery.drain.fg"
|
||||
const val BATTERY_DRAIN_BG = "battery.drain.bg"
|
||||
|
||||
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<String, Long>.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
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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,
|
||||
val relayConnects: Long,
|
||||
val relayConnectFails: Long,
|
||||
val cpuMs: Long,
|
||||
val foregroundMs: Long,
|
||||
val verifyCount: Long,
|
||||
val verifyUs: Long,
|
||||
val httpRequests: Long,
|
||||
val radioBursts: Long,
|
||||
val httpActiveMs: Long,
|
||||
val mediaPlayMs: Long,
|
||||
val powMs: Long,
|
||||
val torMs: Long,
|
||||
val torStarts: Long,
|
||||
val alwaysOnMs: Long,
|
||||
val alwaysOnStarts: Long,
|
||||
val callMs: Long,
|
||||
val nestsMs: Long,
|
||||
val locationMs: Long,
|
||||
val decryptCount: Long,
|
||||
val decryptUs: Long,
|
||||
val signNip46: Long,
|
||||
val signNip55: Long,
|
||||
val batteryDrainFg: Long,
|
||||
val batteryDrainBg: Long,
|
||||
/** total rx+tx bytes per subsystem (net roles + "relay"). */
|
||||
val bytesPerSubsystem: Map<String, Long>,
|
||||
/** cellular-only rx+tx bytes per subsystem — the scarce resource. */
|
||||
val mobileBytesPerSubsystem: Map<String, Long>,
|
||||
/** foreground time per screen NAME (arguments are never recorded). */
|
||||
val screenTimeMs: Map<String, Long>,
|
||||
/** how many day buckets this summary was built from (>= 1). */
|
||||
val dayCount: Int,
|
||||
) {
|
||||
val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg
|
||||
val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg
|
||||
val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg
|
||||
|
||||
companion object {
|
||||
fun from(
|
||||
counters: Map<String, Long>,
|
||||
dayCount: Int = 1,
|
||||
): UsageSummary {
|
||||
fun traffic(
|
||||
net: String,
|
||||
vis: String,
|
||||
) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX)
|
||||
|
||||
val subsystems = mutableMapOf<String, Long>()
|
||||
val mobileSubsystems = mutableMapOf<String, Long>()
|
||||
for (role in UsageKeys.HTTP_ROLES) {
|
||||
val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX)
|
||||
if (bytes > 0) subsystems[role] = bytes
|
||||
val mobileBytes =
|
||||
counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.RX) +
|
||||
counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.TX)
|
||||
if (mobileBytes > 0) mobileSubsystems[role] = mobileBytes
|
||||
}
|
||||
val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX)
|
||||
if (relayBytes > 0) subsystems["relay"] = relayBytes
|
||||
val mobileRelayBytes =
|
||||
counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.RX) +
|
||||
counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.TX)
|
||||
if (mobileRelayBytes > 0) mobileSubsystems["relay"] = mobileRelayBytes
|
||||
|
||||
val screens = mutableMapOf<String, Long>()
|
||||
for ((key, value) in counters) {
|
||||
if (key.startsWith(UsageKeys.SCREEN_PREFIX) && key.endsWith(".ms") && value > 0) {
|
||||
val name = key.removePrefix(UsageKeys.SCREEN_PREFIX).removeSuffix(".ms")
|
||||
if (name.isNotBlank()) screens[name] = (screens[name] ?: 0L) + value
|
||||
}
|
||||
}
|
||||
|
||||
return UsageSummary(
|
||||
mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG),
|
||||
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,
|
||||
relayConnects = counters.sumMatching("connects"),
|
||||
relayConnectFails = counters.sumMatching("connfails"),
|
||||
cpuMs = counters[UsageKeys.CPU_MS] ?: 0L,
|
||||
foregroundMs = counters[UsageKeys.APP_FG_MS] ?: 0L,
|
||||
verifyCount = counters[UsageKeys.VERIFY_COUNT] ?: 0L,
|
||||
verifyUs = counters[UsageKeys.VERIFY_US] ?: 0L,
|
||||
httpRequests = counters.sumMatching("reqs"),
|
||||
radioBursts = counters.sumMatching("bursts"),
|
||||
httpActiveMs = counters.sumMatching("activems"),
|
||||
mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L,
|
||||
powMs = counters[UsageKeys.POW_MS] ?: 0L,
|
||||
torMs = counters[UsageKeys.TOR_MS] ?: 0L,
|
||||
torStarts = counters[UsageKeys.TOR_STARTS] ?: 0L,
|
||||
alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L,
|
||||
alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L,
|
||||
callMs = counters[UsageKeys.CALL_MS] ?: 0L,
|
||||
nestsMs = counters[UsageKeys.NESTS_MS] ?: 0L,
|
||||
locationMs = counters[UsageKeys.LOCATION_MS] ?: 0L,
|
||||
decryptCount = counters[UsageKeys.DECRYPT_COUNT] ?: 0L,
|
||||
decryptUs = counters[UsageKeys.DECRYPT_US] ?: 0L,
|
||||
signNip46 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP46)] ?: 0L,
|
||||
signNip55 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP55)] ?: 0L,
|
||||
batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L,
|
||||
batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L,
|
||||
bytesPerSubsystem = subsystems,
|
||||
mobileBytesPerSubsystem = mobileSubsystems,
|
||||
screenTimeMs = screens,
|
||||
dayCount = dayCount.coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
/** Merges several day buckets and summarizes the total. */
|
||||
fun fromDays(days: Collection<Map<String, Long>>): UsageSummary {
|
||||
val merged = mutableMapOf<String, Long>()
|
||||
days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } }
|
||||
return from(merged, dayCount = days.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -206,9 +206,28 @@ class ScheduledPostStore(
|
||||
mutableListOf()
|
||||
}
|
||||
loaded = true
|
||||
val recovered = releaseStaleClaims()
|
||||
val purged = purgeStale(nowSec())
|
||||
_flow.value = posts.toList()
|
||||
if (purged) persist()
|
||||
if (purged || recovered) persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* A PUBLISHING row found at initial disk load is a claim from a previous
|
||||
* process — the worker that held it died (crash, cancellation) before
|
||||
* markSent/markFailed/releaseClaim ran. No worker in THIS process can
|
||||
* hold a claim before the first load, so reset them to PENDING for the
|
||||
* next cycle to retry. Returns true if any row was recovered.
|
||||
*/
|
||||
private fun releaseStaleClaims(): Boolean {
|
||||
var recovered = false
|
||||
posts.forEachIndexed { idx, post ->
|
||||
if (post.status == ScheduledPostStatus.PUBLISHING) {
|
||||
posts[idx] = post.copy(status = ScheduledPostStatus.PENDING)
|
||||
recovered = true
|
||||
}
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.scheduledposts
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Keeps [ScheduledPostWorker]'s 15-minute periodic chain enqueued exactly while
|
||||
* the store holds a PENDING post. An unconditionally-scheduled periodic worker
|
||||
* wakes — and often cold-starts — the whole process every 15 minutes forever,
|
||||
* even for users who never schedule a post.
|
||||
*
|
||||
* The store is durable (JSON on disk) and the single source of truth, so
|
||||
* [onPendingWork] fires from any mutation that produces a PENDING row (add,
|
||||
* publishNow, releaseClaim) and [onNoPendingWork] when the last one drains
|
||||
* (markSent/markFailed/cancel/removeForAccount).
|
||||
*
|
||||
* PUBLISHING counts as pending work: claimDuePosts flips PENDING → PUBLISHING
|
||||
* (and emits) BEFORE the worker publishes, so gating on PENDING alone would
|
||||
* cancel the periodic worker mid-publish the moment it claims the last post —
|
||||
* stranding the post in PUBLISHING with nothing left to finish or retry it.
|
||||
*
|
||||
* [start] forces the store's initial disk load BEFORE collecting: the flow's
|
||||
* initial value is an empty list until the store is first touched, and acting
|
||||
* on that placeholder would cancel scheduled work that a pending post still
|
||||
* needs.
|
||||
*/
|
||||
class ScheduledPostWorkGate(
|
||||
private val store: ScheduledPostStore,
|
||||
private val scope: CoroutineScope,
|
||||
private val onPendingWork: () -> Unit,
|
||||
private val onNoPendingWork: () -> Unit,
|
||||
) {
|
||||
fun start(): Job =
|
||||
scope.launch {
|
||||
store.list()
|
||||
store.flow
|
||||
.map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING || it.status == ScheduledPostStatus.PUBLISHING } }
|
||||
.distinctUntilChanged()
|
||||
.collect { hasPending ->
|
||||
if (hasPending) {
|
||||
onPendingWork()
|
||||
} else {
|
||||
onNoPendingWork()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -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
|
||||
@@ -42,10 +43,15 @@ import java.util.concurrent.TimeUnit
|
||||
/**
|
||||
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
|
||||
*
|
||||
* - schedule(context): periodic, every 15 min (WorkManager minimum).
|
||||
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
|
||||
* due while the device was off or while WorkManager
|
||||
* was deferred by Doze.
|
||||
* - schedule(context): periodic, every 15 min (WorkManager minimum). Only
|
||||
* enqueued while the store holds a PENDING post — the
|
||||
* store observer in AppModules schedules it when a
|
||||
* pending post appears and cancels it when the last
|
||||
* one drains, so the worker never wakes the process
|
||||
* with nothing to publish.
|
||||
* - scheduleCatchUp(context): one-time, to flush posts that came due while the
|
||||
* device was off or while WorkManager was deferred
|
||||
* by Doze.
|
||||
*/
|
||||
class ScheduledPostWorker(
|
||||
appContext: Context,
|
||||
@@ -107,6 +113,16 @@ class ScheduledPostWorker(
|
||||
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the 15-minute periodic chain (keeps any catch-up run). Called by the
|
||||
* store observer in AppModules when the last PENDING post drains, so the
|
||||
* worker doesn't keep waking the process with nothing to publish.
|
||||
*/
|
||||
fun cancelPeriodic(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.d(TAG) { "cancelPeriodic(): cancelled periodic worker" }
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
|
||||
@@ -117,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
|
||||
|
||||
@@ -42,12 +42,16 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
|
||||
@@ -228,6 +232,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
|
||||
@@ -301,11 +306,13 @@ fun AppNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
TrackScreenTime(nav)
|
||||
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
|
||||
|
||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||
DisplayNotifyMessages(accountViewModel, nav)
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayResourceUsageAlert(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
|
||||
ObserveIncomingCalls(accountViewModel)
|
||||
@@ -324,6 +331,27 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the resource-usage ledger with time-per-screen. Only the route's
|
||||
* base name crosses this boundary — [ScreenTimeIntegrator.screenNameOf]
|
||||
* strips every navigation argument first, so the ledger can say "Profile"
|
||||
* but never which profile.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrackScreenTime(nav: Nav) {
|
||||
DisposableEffect(nav.controller) {
|
||||
val listener =
|
||||
NavController.OnDestinationChangedListener { _, destination, _ ->
|
||||
Amethyst.instance.screenTime.onScreen(ScreenTimeIntegrator.screenNameOf(destination.route))
|
||||
}
|
||||
nav.controller.addOnDestinationChangedListener(listener)
|
||||
onDispose {
|
||||
nav.controller.removeOnDestinationChangedListener(listener)
|
||||
Amethyst.instance.screenTime.onScreen(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BuildNavigation(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -481,6 +509,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ResourceUsage> { ResourceUsageScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
|
||||
|
||||
@@ -422,6 +422,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object CalendarReminderSettings : Route()
|
||||
|
||||
@Serializable object ResourceUsage : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* 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.navigation.topbars
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.MemorySnapshot
|
||||
import com.vitorpamplona.amethyst.collectMemorySnapshot
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun MemoryUsageChip() {
|
||||
if (!isDebug) return
|
||||
|
||||
val context = LocalContext.current
|
||||
var showDetail by remember { mutableStateOf(false) }
|
||||
|
||||
val snapshot by produceState<MemorySnapshot?>(null) {
|
||||
while (true) {
|
||||
// collectMemorySnapshot reads coil3.disk.DiskLruCache.size(), which is @Synchronized and
|
||||
// contends with the disk cache's own journal I/O. On cold start that lock is held by a
|
||||
// background worker for seconds (initial journal read + the burst of image writes), so
|
||||
// running this on the produceState default (main) dispatcher froze the UI thread —
|
||||
// the "Loading account" frame couldn't repaint until size() returned. Collect off-main.
|
||||
value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) }
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
|
||||
val s = snapshot ?: return
|
||||
|
||||
val chipColor =
|
||||
when {
|
||||
s.heapFraction > 0.80f -> Color(0xFFE53935) // red
|
||||
s.heapFraction > 0.60f -> Color(0xFFFFA000) // amber
|
||||
else -> Color(0xFF43A047) // green
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${s.heapUsedMb}/${s.heapMaxMb}MB",
|
||||
color = chipColor,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.clickable { showDetail = true },
|
||||
)
|
||||
|
||||
if (showDetail) {
|
||||
MemoryDetailDialog(snapshot = s, onDismiss = { showDetail = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemoryDetailDialog(
|
||||
snapshot: MemorySnapshot,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Memory Usage") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Device class: ${snapshot.memoryClassMb} MB")
|
||||
Text("JVM heap: ${snapshot.heapUsedMb} / ${snapshot.heapMaxMb} MB")
|
||||
Text("Native heap: ${snapshot.nativeHeapUsedMb} MB")
|
||||
Text("Coil memory: ${snapshot.imageCacheUsedMb} / ${snapshot.imageCacheMaxMb} MB")
|
||||
Text("Coil disk: ${snapshot.imageDiskUsedMb} / ${snapshot.imageDiskMaxMb} MB")
|
||||
Text("Notes: ${snapshot.noteCount}")
|
||||
Text("Users: ${snapshot.userCount}")
|
||||
Text("Addressables: ${snapshot.addressableCount}")
|
||||
Text("Chatrooms: ${snapshot.chatroomCount}")
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("OK") }
|
||||
},
|
||||
)
|
||||
}
|
||||
-1
@@ -75,7 +75,6 @@ fun UserDrawerSearchTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
MemoryUsageChip()
|
||||
IconButton(onClick = { nav.nav(Route.Search) }) {
|
||||
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
+2
-15
@@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayA
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -172,20 +172,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
|
||||
val externalSignerLauncher =
|
||||
when (val signer = accountViewModel.account.signer) {
|
||||
is IActivityLauncher -> {
|
||||
signer
|
||||
}
|
||||
|
||||
is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> {
|
||||
signer.inner as IActivityLauncher
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
val externalSignerLauncher = accountViewModel.account.signer.innermostSigner() as? IActivityLauncher
|
||||
|
||||
if (externalSignerLauncher != null) {
|
||||
val launcher =
|
||||
|
||||
+8
-5
@@ -105,11 +105,14 @@ fun CalendarReminderSettingsScreen(nav: INav) {
|
||||
onCheckedChange = {
|
||||
enabled = it
|
||||
prefs.setEnabled(it)
|
||||
// Toggling off doesn't cancel the worker — the worker itself short-
|
||||
// circuits when isEnabled() returns false. Keeping the schedule alive
|
||||
// means flipping it back on takes effect immediately without needing a
|
||||
// re-launch via AppModules.
|
||||
if (it) CalendarReminderWorker.schedule(context)
|
||||
// Cancel eagerly on disable so the periodic worker stops waking the
|
||||
// process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP
|
||||
// observer in AppModules re-schedules on the next relevant RSVP too.
|
||||
if (it) {
|
||||
CalendarReminderWorker.schedule(context)
|
||||
} else {
|
||||
CalendarReminderWorker.cancel(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.MemorySnapshot
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.collectMemorySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
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.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Layout: headline stat tiles (today) → 7-day trend chart → per-feature
|
||||
* proportion bars → activity counters → live memory meters → send card.
|
||||
*/
|
||||
@Composable
|
||||
fun ResourceUsageScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var days by remember { mutableStateOf<Map<Long, Map<String, Long>>?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
days = Amethyst.instance.resourceUsage.allDaysIncludingLive()
|
||||
}
|
||||
}
|
||||
|
||||
// Live memory snapshot, refreshed only while this screen is composed.
|
||||
// Collected off-main: DiskLruCache.size() contends with journal I/O.
|
||||
val context = LocalContext.current
|
||||
val memory by produceState<MemorySnapshot?>(null) {
|
||||
while (true) {
|
||||
value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) }
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// remember(loaded): the memory produceState recomposes this
|
||||
// scope every 2s, and the summaries walk every counter of
|
||||
// every retained day — recompute only when the data reloads.
|
||||
val today = remember(loaded) { Amethyst.instance.resourceUsage.today() }
|
||||
val todaySummary = remember(loaded) { UsageSummary.from(loaded[today].orEmpty()) }
|
||||
val weekSummary = remember(loaded) { UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) }
|
||||
|
||||
TodayTiles(todaySummary)
|
||||
WeekRatesTiles(weekSummary)
|
||||
if (weekSummary.totalBytes > 0) {
|
||||
SettingsSection(R.string.resource_usage_trend_section) {
|
||||
UsageTrendChart(loaded, today)
|
||||
}
|
||||
}
|
||||
SubsystemSection(weekSummary)
|
||||
ScreenTimeSection(weekSummary)
|
||||
ActivitySection(weekSummary)
|
||||
AlwaysOnServiceSection(weekSummary, nav)
|
||||
TorServiceSection(weekSummary, nav)
|
||||
MemorySection(memory)
|
||||
SendReportSection(accountViewModel, nav, loaded, today, memory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Headline numbers for today as a 2x2 tile grid. */
|
||||
@Composable
|
||||
private fun TodayTiles(s: UsageSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
StatTile(R.string.resource_usage_tile_cellular, formatBytes(s.mobileBytes), Modifier.weight(1f))
|
||||
StatTile(R.string.resource_usage_tile_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg), Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
StatTile(R.string.resource_usage_tile_relay, formatConnHours(s.relayConnMs), Modifier.weight(1f))
|
||||
StatTile(R.string.resource_usage_tile_in_app, formatDurationMs(s.foregroundMs), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatTile(
|
||||
@StringRes label: Int,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 7-day rates: totals aren't judgeable, rates are. Battery %/hour of use is
|
||||
* the most tangible battery number we can show; average simultaneous relay
|
||||
* connections is the number a user can act on by trimming their relay list.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekRatesTiles(s: UsageSummary) {
|
||||
val fgHours = s.foregroundMs / 3_600_000.0
|
||||
val tiles =
|
||||
buildList {
|
||||
if (fgHours >= 0.5 && s.batteryDrainFg > 0) {
|
||||
add(R.string.resource_usage_tile_battery_rate to String.format(Locale.US, "%.1f%%", s.batteryDrainFg / fgHours))
|
||||
}
|
||||
if (fgHours >= 0.5) {
|
||||
add(R.string.resource_usage_tile_data_rate to formatBytes(((s.mobileBytesFg + s.wifiBytesFg) / fgHours).toLong()))
|
||||
}
|
||||
val avgRelays = s.relayConnMs.toDouble() / (s.dayCount * ResourceUsageAccountant.DAY_MS)
|
||||
if (avgRelays >= 0.5) {
|
||||
add(R.string.resource_usage_tile_avg_relays to String.format(Locale.US, "%.1f", avgRelays))
|
||||
}
|
||||
}
|
||||
if (tiles.isEmpty()) return
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
tiles.forEach { (label, value) -> StatTile(label, value, Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranked per-feature traffic with proportion bars (7 days). Cellular is the
|
||||
* scarce resource (battery and often money), so when any cellular traffic
|
||||
* exists the ranking, bars, and headline value are cellular — with the total
|
||||
* as secondary context. Wi-Fi-only devices fall back to totals.
|
||||
*/
|
||||
@Composable
|
||||
private fun SubsystemSection(week: UsageSummary) {
|
||||
val cellular = week.mobileBytesPerSubsystem
|
||||
if (cellular.isNotEmpty()) {
|
||||
val rows = cellular.entries.sortedByDescending { it.value }
|
||||
val max = rows.first().value.coerceAtLeast(1L)
|
||||
SettingsSection(R.string.resource_usage_by_subsystem_cellular) {
|
||||
rows.forEach { (subsystem, bytes) ->
|
||||
BarRow(
|
||||
label = subsystemLabel(subsystem),
|
||||
value = stringRes(R.string.resource_usage_cell_of_total, formatBytes(bytes), formatBytes(week.bytesPerSubsystem[subsystem] ?: bytes)),
|
||||
fraction = bytes.toFloat() / max.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (week.bytesPerSubsystem.isEmpty()) return
|
||||
val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value }
|
||||
val max = rows.first().value.coerceAtLeast(1L)
|
||||
SettingsSection(R.string.resource_usage_by_subsystem) {
|
||||
rows.forEach { (subsystem, bytes) ->
|
||||
BarRow(
|
||||
label = subsystemLabel(subsystem),
|
||||
value = formatBytes(bytes),
|
||||
fraction = bytes.toFloat() / max.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the screen-on time went (7 days). Route base names only — the
|
||||
* ledger never records which profile/hashtag/thread a screen showed.
|
||||
*/
|
||||
@Composable
|
||||
private fun ScreenTimeSection(week: UsageSummary) {
|
||||
if (week.screenTimeMs.isEmpty()) return
|
||||
val rows =
|
||||
week.screenTimeMs.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(6)
|
||||
val max = rows.first().value.coerceAtLeast(1L)
|
||||
SettingsSection(R.string.resource_usage_screen_time_section) {
|
||||
rows.forEach { (name, ms) ->
|
||||
BarRow(
|
||||
label = name,
|
||||
value = formatDurationMs(ms),
|
||||
fraction = ms.toFloat() / max.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun subsystemLabel(subsystem: String): Int =
|
||||
when (subsystem) {
|
||||
"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 BarRow(
|
||||
@StringRes label: Int,
|
||||
value: String,
|
||||
fraction: Float,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
) = BarRow(stringRes(label), value, fraction, color)
|
||||
|
||||
/** Label + value + a thin rounded proportion bar underneath. */
|
||||
@Composable
|
||||
private fun BarRow(
|
||||
label: String,
|
||||
value: String,
|
||||
fraction: Float,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(fraction.coerceIn(0f, 1f))
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Background/CPU activity counters for the last 7 days. */
|
||||
@Composable
|
||||
private fun ActivitySection(s: UsageSummary) {
|
||||
SettingsSection(R.string.resource_usage_activity_section) {
|
||||
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_reconnects, "${s.relayConnects} (${s.relayConnectFails})")
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_http_requests, s.httpRequests.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_radio_bursts, s.radioBursts.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_http_active, formatDurationMs(s.httpActiveMs))
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_media_play, formatDurationMs(s.mediaPlayMs))
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs))
|
||||
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())
|
||||
if (s.decryptCount > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_decrypts, s.decryptCount.toString())
|
||||
}
|
||||
if (s.signNip46 + s.signNip55 > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_remote_signs, (s.signNip46 + s.signNip55).toString())
|
||||
}
|
||||
if (s.powMs > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_pow, formatDurationMs(s.powMs))
|
||||
}
|
||||
if (s.torMs > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_tor, formatDurationMs(s.torMs))
|
||||
}
|
||||
if (s.callMs > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_calls, formatDurationMs(s.callMs))
|
||||
}
|
||||
if (s.nestsMs > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_nests, formatDurationMs(s.nestsMs))
|
||||
}
|
||||
if (s.locationMs > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_location, formatDurationMs(s.locationMs))
|
||||
}
|
||||
if (s.batteryDrainFg + s.batteryDrainBg > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_battery_drain, "${s.batteryDrainFg}% / ${s.batteryDrainBg}%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live memory meters. The heap bar keeps the retired debug chip's status
|
||||
* semantics: green below 60% of the max heap, amber to 80%, red above.
|
||||
*/
|
||||
@Composable
|
||||
private fun MemorySection(memory: MemorySnapshot?) {
|
||||
if (memory == null) return
|
||||
val heapColor =
|
||||
when {
|
||||
memory.heapFraction > 0.80f -> Color(0xFFE53935)
|
||||
memory.heapFraction > 0.60f -> Color(0xFFFFA000)
|
||||
else -> Color(0xFF43A047)
|
||||
}
|
||||
SettingsSection(R.string.resource_usage_memory_section) {
|
||||
BarRow(
|
||||
label = R.string.resource_usage_memory_heap,
|
||||
value = "${memory.heapUsedMb} / ${memory.heapMaxMb} MB",
|
||||
fraction = memory.heapFraction,
|
||||
color = heapColor,
|
||||
)
|
||||
BarRow(
|
||||
label = R.string.resource_usage_memory_image_cache,
|
||||
value = "${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB",
|
||||
fraction = memory.imageCacheUsedMb.toFloat() / memory.imageCacheMaxMb.coerceAtLeast(1L).toFloat(),
|
||||
)
|
||||
BarRow(
|
||||
label = R.string.resource_usage_memory_image_disk,
|
||||
value = "${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB",
|
||||
fraction = memory.imageDiskUsedMb.toFloat() / memory.imageDiskMaxMb.coerceAtLeast(1L).toFloat(),
|
||||
)
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_memory_native, "${memory.nativeHeapUsedMb} MB")
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_memory_notes, memory.noteCount.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_memory_users, memory.userCount.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_memory_addressables, memory.addressableCount.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_memory_chatrooms, memory.chatroomCount.toString())
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_memory_device_class, "${memory.memoryClassMb} MB")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision-support card for the always-on notification service — the one
|
||||
* background consumer users can directly turn off. Shows what the service
|
||||
* actually costs in the last 7 days (uptime, the relay connections it holds
|
||||
* while the app is closed, and the measured background battery drain) and
|
||||
* links straight to the setting that controls it.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlwaysOnServiceSection(
|
||||
s: UsageSummary,
|
||||
nav: INav,
|
||||
) {
|
||||
if (s.alwaysOnMs <= 0) return
|
||||
val starts = s.alwaysOnStarts.toInt()
|
||||
SettingsSection(R.string.resource_usage_alwayson_section) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.resource_usage_alwayson_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MetricRow(
|
||||
R.string.resource_usage_alwayson_uptime,
|
||||
"${formatDurationMs(s.alwaysOnMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}",
|
||||
)
|
||||
SettingsDivider()
|
||||
MetricRow(
|
||||
R.string.resource_usage_alwayson_bg_relay,
|
||||
formatConnHours(s.relayConnMsMobileBg + s.relayConnMsWifiBg),
|
||||
)
|
||||
if (s.batteryDrainBg > 0) {
|
||||
SettingsDivider()
|
||||
MetricRow(R.string.resource_usage_alwayson_battery, "${s.batteryDrainBg}%")
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
TextButton(
|
||||
onClick = { nav.nav(Route.NotificationSettings) },
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringRes(R.string.resource_usage_alwayson_settings_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost card for in-app Tor — like the always-on card: what it cost this
|
||||
* week and the settings surface that controls it.
|
||||
*/
|
||||
@Composable
|
||||
private fun TorServiceSection(
|
||||
s: UsageSummary,
|
||||
nav: INav,
|
||||
) {
|
||||
if (s.torMs <= 0) return
|
||||
val starts = s.torStarts.toInt()
|
||||
SettingsSection(R.string.resource_usage_tor_section) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.resource_usage_tor_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MetricRow(
|
||||
R.string.resource_usage_alwayson_uptime,
|
||||
"${formatDurationMs(s.torMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}",
|
||||
)
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
TextButton(
|
||||
onClick = { nav.nav(Route.PrivacyOptions) },
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringRes(R.string.resource_usage_tor_settings_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendReportSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
days: Map<Long, Map<String, Long>>,
|
||||
today: Long,
|
||||
memory: MemorySnapshot?,
|
||||
) {
|
||||
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, memory)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.LineCartesianLayerModel
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.LastWeekLabelFormatter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.makeLine
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Cellular-on-dark-surfaces variant of BitcoinOrange. The light/dark pairs
|
||||
* (#F7931A + #4169E1 on light; #C77414 + #4169E1 on dark) were validated for
|
||||
* CVD separation, lightness band, and chroma with the dataviz palette checks;
|
||||
* the light orange's low surface contrast is relieved by the text legend.
|
||||
*/
|
||||
private val CellularOnDark = Color(0xFFC77414)
|
||||
|
||||
/**
|
||||
* 7-day data-per-day trend: one line per network class, MB per day, today at
|
||||
* the right edge. Reuses the notification chart's Vico idioms ([makeLine],
|
||||
* [LastWeekLabelFormatter], x = -6..0 with today at 0).
|
||||
*/
|
||||
@Composable
|
||||
fun UsageTrendChart(
|
||||
days: Map<Long, Map<String, Long>>,
|
||||
today: Long,
|
||||
) {
|
||||
val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||
val cellularColor = if (isDark) CellularOnDark else BitcoinOrange
|
||||
val wifiColor = RoyalBlue
|
||||
|
||||
val model =
|
||||
remember(days, today) {
|
||||
val xs = (-6..0).toList()
|
||||
val cellular =
|
||||
xs.map { offset ->
|
||||
UsageSummary.from(days[today + offset].orEmpty()).mobileBytes.toMb()
|
||||
}
|
||||
val wifi =
|
||||
xs.map { offset ->
|
||||
val s = UsageSummary.from(days[today + offset].orEmpty())
|
||||
(s.wifiBytesBg + s.wifiBytesFg).toMb()
|
||||
}
|
||||
CartesianChartModel(
|
||||
LineCartesianLayerModel.build {
|
||||
series(xs, cellular)
|
||||
series(xs, wifi)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val chart =
|
||||
rememberCartesianChart(
|
||||
layers =
|
||||
arrayOf(
|
||||
LineCartesianLayer(
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
makeLine(cellularColor),
|
||||
makeLine(wifiColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
startAxis =
|
||||
VerticalAxis.rememberStart(
|
||||
valueFormatter = MegabyteAxisFormatter,
|
||||
itemPlacer = VerticalAxis.ItemPlacer.count({ 5 }),
|
||||
),
|
||||
bottomAxis =
|
||||
HorizontalAxis.rememberBottom(
|
||||
valueFormatter = LastWeekLabelFormatter(),
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CartesianChartHost(
|
||||
chart = chart,
|
||||
model = model,
|
||||
modifier = Modifier.fillMaxWidth().height(170.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
LegendEntry(cellularColor, R.string.resource_usage_legend_cellular)
|
||||
LegendEntry(wifiColor, R.string.resource_usage_legend_wifi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendEntry(
|
||||
color: Color,
|
||||
@StringRes label: Int,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(Modifier.size(10.dp).background(color, CircleShape))
|
||||
Text(
|
||||
text = stringRes(label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toMb(): Float = this / 1_048_576f
|
||||
|
||||
private val MegabyteAxisFormatter =
|
||||
object : CartesianValueFormatter {
|
||||
override fun format(
|
||||
context: CartesianMeasuringContext,
|
||||
value: Double,
|
||||
verticalAxisPosition: Axis.Position.Vertical?,
|
||||
): CharSequence =
|
||||
if (value >= 1024) {
|
||||
"%.1fG".format(value / 1024.0)
|
||||
} else {
|
||||
"${value.roundToInt()}M"
|
||||
}
|
||||
}
|
||||
@@ -3182,6 +3182,102 @@
|
||||
<string name="crashreport_found">Crash Report found</string>
|
||||
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared</string>
|
||||
<string name="crashreport_found_send">Send it</string>
|
||||
|
||||
<string name="resource_usage_title">App resource usage</string>
|
||||
<string name="resource_usage_search_keywords">battery data usage consumption power network diagnostics ledger</string>
|
||||
<string name="resource_usage_today">Today</string>
|
||||
<string name="resource_usage_week">Last 7 days</string>
|
||||
<string name="resource_usage_tile_cellular">Cellular today</string>
|
||||
<string name="resource_usage_tile_wifi">Wi-Fi today</string>
|
||||
<string name="resource_usage_tile_relay">Relay time today</string>
|
||||
<string name="resource_usage_tile_in_app">In app today</string>
|
||||
<string name="resource_usage_trend_section">Data per day</string>
|
||||
<string name="resource_usage_legend_cellular">Cellular (MB)</string>
|
||||
<string name="resource_usage_legend_wifi">Wi-Fi (MB)</string>
|
||||
<string name="resource_usage_activity_section">Activity (7 days)</string>
|
||||
<string name="resource_usage_cellular_bg">Cellular data (background)</string>
|
||||
<string name="resource_usage_cellular_fg">Cellular data (in app)</string>
|
||||
<string name="resource_usage_wifi">Wi-Fi data</string>
|
||||
<string name="resource_usage_relay_time">Relay connection time</string>
|
||||
<string name="resource_usage_relay_time_bg_mobile">\u2026 backgrounded on cellular</string>
|
||||
<string name="resource_usage_wakelock">Notification processing (CPU awake)</string>
|
||||
<string name="resource_usage_worker_runs">Background jobs run</string>
|
||||
<string name="resource_usage_reconnects">Relay reconnections (failed)</string>
|
||||
<string name="resource_usage_http_requests">Web requests</string>
|
||||
<string name="resource_usage_radio_bursts">Radio wake-ups for downloads (est.)</string>
|
||||
<string name="resource_usage_http_active">Active transfer time</string>
|
||||
<string name="resource_usage_media_play">Media playback time</string>
|
||||
<string name="resource_usage_verifies">Signatures verified</string>
|
||||
<string name="resource_usage_cpu">Processor time used</string>
|
||||
<string name="resource_usage_fg_time">Time in app</string>
|
||||
<string name="resource_usage_app_starts">App process starts</string>
|
||||
<string name="resource_usage_decrypts">Messages decrypted</string>
|
||||
<string name="resource_usage_remote_signs">Remote signatures</string>
|
||||
<string name="resource_usage_pow">Proof-of-work mining</string>
|
||||
<string name="resource_usage_tor">Tor uptime</string>
|
||||
<string name="resource_usage_calls">Time in calls</string>
|
||||
<string name="resource_usage_nests">Time in audio rooms</string>
|
||||
<string name="resource_usage_location">Location listening</string>
|
||||
<string name="resource_usage_battery_drain">Battery used (in app / background)</string>
|
||||
<string name="resource_usage_alwayson_section">Background notification service</string>
|
||||
<string name="resource_usage_alwayson_explanation">Keeps connections to your relays open while the app is closed, so notifications arrive without Google’s push servers. While enabled, this is usually the largest background battery cost. If battery matters more to you than instant notifications, you can change the delivery method or turn it off.</string>
|
||||
<string name="resource_usage_alwayson_uptime">Running time</string>
|
||||
<plurals name="resource_usage_alwayson_starts">
|
||||
<item quantity="one">%1$d start</item>
|
||||
<item quantity="other">%1$d starts</item>
|
||||
</plurals>
|
||||
<string name="resource_usage_alwayson_bg_relay">Relay connections held while closed</string>
|
||||
<string name="resource_usage_alwayson_battery">Battery drained meanwhile (whole device)</string>
|
||||
<string name="resource_usage_alwayson_settings_button">Change notification settings</string>
|
||||
<string name="resource_usage_tor_settings_button">Privacy options</string>
|
||||
<string name="resource_usage_tile_battery_rate">Battery / hour in app</string>
|
||||
<string name="resource_usage_tile_data_rate">Data / hour in app</string>
|
||||
<string name="resource_usage_tile_avg_relays">Avg. relay connections</string>
|
||||
<string name="resource_usage_by_subsystem_cellular">Cellular data by feature (7 days)</string>
|
||||
<string name="resource_usage_cell_of_total">%1$s · %2$s total</string>
|
||||
<string name="resource_usage_screen_time_section">Time by screen (7 days)</string>
|
||||
<string name="resource_usage_tor_section">Built-in Tor</string>
|
||||
<string name="resource_usage_tor_explanation">Routes the app’s traffic through the Tor network for privacy. While running it spends extra battery on encryption and keep-alive traffic, and every start pays a bootstrap. If your threat model allows, Tor use can be adjusted or turned off.</string>
|
||||
<string name="resource_usage_by_subsystem">Data by feature (7 days)</string>
|
||||
<string name="resource_usage_subsystem_relay">Relay sync</string>
|
||||
<string name="resource_usage_subsystem_image">Images</string>
|
||||
<string name="resource_usage_subsystem_video">Video & audio</string>
|
||||
<string name="resource_usage_subsystem_uploads">Uploads</string>
|
||||
<string name="resource_usage_subsystem_money">Wallet & zaps</string>
|
||||
<string name="resource_usage_subsystem_nip05">Address verification</string>
|
||||
<string name="resource_usage_subsystem_preview">Link previews</string>
|
||||
<string name="resource_usage_subsystem_push">Push registration</string>
|
||||
<string name="resource_usage_subsystem_other">Other</string>
|
||||
<string name="resource_usage_empty">No usage recorded yet.</string>
|
||||
<string name="resource_usage_memory_section">Memory right now</string>
|
||||
<string name="resource_usage_memory_heap">App memory (heap)</string>
|
||||
<string name="resource_usage_memory_native">Native memory</string>
|
||||
<string name="resource_usage_memory_image_cache">Image cache (RAM)</string>
|
||||
<string name="resource_usage_memory_image_disk">Image cache (disk)</string>
|
||||
<string name="resource_usage_memory_notes">Notes in memory</string>
|
||||
<string name="resource_usage_memory_users">Profiles in memory</string>
|
||||
<string name="resource_usage_memory_addressables">Addressable events in memory</string>
|
||||
<string name="resource_usage_memory_chatrooms">Chatroom lists in memory</string>
|
||||
<string name="resource_usage_memory_device_class">Device memory class</string>
|
||||
<string name="resource_usage_send_section">Share with the developers</string>
|
||||
<string name="resource_usage_send_explanation">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.</string>
|
||||
<string name="resource_usage_send_button">Send report via DM</string>
|
||||
<string name="resource_usage_alert_title">High resource usage detected</string>
|
||||
<string name="resource_usage_alert_message">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.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s of cellular data in the background in one day</string>
|
||||
<string name="resource_usage_reason_conn_time">%1$s of relay connections while backgrounded on cellular in one day</string>
|
||||
<string name="resource_usage_reason_wakelock">the CPU was held awake for %1$s processing notifications in one day</string>
|
||||
<plurals name="resource_usage_reason_churn">
|
||||
<item quantity="one">the app process restarted %1$d time in one day</item>
|
||||
<item quantity="other">the app process restarted %1$d times in one day</item>
|
||||
</plurals>
|
||||
<plurals name="resource_usage_reason_reconnects">
|
||||
<item quantity="one">the app reconnected to relays %1$d time in one day</item>
|
||||
<item quantity="other">the app reconnected to relays %1$d times in one day</item>
|
||||
</plurals>
|
||||
<string name="resource_usage_alert_send">Review & send</string>
|
||||
<string name="resource_usage_alert_not_now">Not now</string>
|
||||
<string name="resource_usage_alert_opt_out">Don\u2019t ask again</string>
|
||||
<string name="this_message_will_disappear_in">This message will disappear in %1$s</string>
|
||||
<string name="select_signer">Select Signer</string>
|
||||
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The predicate that decides whether the CalendarReminderWorker's periodic
|
||||
* chain may cancel itself. Getting it wrong in either direction is a bug:
|
||||
* a false "could fire" keeps waking the process forever; a false "can't fire"
|
||||
* silently kills a reminder the user RSVP'd to.
|
||||
*/
|
||||
class CalendarReminderCandidatesTest {
|
||||
private val now = 2_000_000_000L
|
||||
|
||||
// Unique per-test-class identities so the shared LocalCache singleton
|
||||
// doesn't collide with other suites running in the same JVM.
|
||||
private val organizer = "b0b0000000000000000000000000000000000000000000000000000000000001"
|
||||
private val attendee = "a11ce00000000000000000000000000000000000000000000000000000000002"
|
||||
|
||||
private var eventSeq = 0
|
||||
|
||||
private fun timeSlot(
|
||||
dTag: String,
|
||||
startSeconds: Long?,
|
||||
) = CalendarTimeSlotEvent(
|
||||
id = "c0ffee%058d".format(++eventSeq),
|
||||
pubKey = organizer,
|
||||
createdAt = now - 1000,
|
||||
tags =
|
||||
if (startSeconds != null) {
|
||||
arrayOf(arrayOf("d", dTag), arrayOf("start", startSeconds.toString()))
|
||||
} else {
|
||||
arrayOf(arrayOf("d", dTag))
|
||||
},
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
private fun rsvp(
|
||||
dTag: String,
|
||||
targetDTag: String?,
|
||||
status: String = "accepted",
|
||||
) = CalendarRSVPEvent(
|
||||
id = "faced%059d".format(++eventSeq),
|
||||
pubKey = attendee,
|
||||
createdAt = now - 900,
|
||||
tags =
|
||||
buildList {
|
||||
add(arrayOf("d", dTag))
|
||||
add(arrayOf("status", status))
|
||||
if (targetDTag != null) {
|
||||
add(arrayOf("a", "${CalendarTimeSlotEvent.KIND}:$organizer:$targetDTag"))
|
||||
}
|
||||
}.toTypedArray(),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun acceptedRsvpsInCache_returnsAcceptedAndSkipsDeclined() {
|
||||
val accepted = rsvp("cand-accepted", "cand-target-1")
|
||||
val declined = rsvp("cand-declined", "cand-target-2", status = "declined")
|
||||
LocalCache.justConsume(accepted, null, true)
|
||||
LocalCache.justConsume(declined, null, true)
|
||||
|
||||
val found = CalendarReminderWorker.acceptedRsvpsInCache()
|
||||
assertTrue("accepted RSVP must be found", found.any { it.dTag() == "cand-accepted" })
|
||||
assertFalse("declined RSVP must be skipped", found.any { it.dTag() == "cand-declined" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun futureTarget_couldStillFire() {
|
||||
val slot = timeSlot("cand-future", startSeconds = now + 3600)
|
||||
val r = rsvp("cand-rsvp-future", "cand-future")
|
||||
LocalCache.justConsume(slot, null, true)
|
||||
LocalCache.justConsume(r, null, true)
|
||||
|
||||
assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastTarget_cannotFireAnymore() {
|
||||
val slot = timeSlot("cand-past", startSeconds = now - 3600)
|
||||
val r = rsvp("cand-rsvp-past", "cand-past")
|
||||
LocalCache.justConsume(slot, null, true)
|
||||
LocalCache.justConsume(r, null, true)
|
||||
|
||||
assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unresolvedTarget_keepsTheChainAlive() {
|
||||
// The RSVP points at an event the cache hasn't fetched yet: the start
|
||||
// is unknown, so the worker must NOT cancel its chain.
|
||||
val r = rsvp("cand-rsvp-unresolved", "cand-never-fetched")
|
||||
LocalCache.justConsume(r, null, true)
|
||||
|
||||
assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun targetWithoutStart_keepsTheChainAlive() {
|
||||
// Target resolved but carries no start tag: still unknown, keep alive.
|
||||
val slot = timeSlot("cand-no-start", startSeconds = null)
|
||||
val r = rsvp("cand-rsvp-no-start", "cand-no-start")
|
||||
LocalCache.justConsume(slot, null, true)
|
||||
LocalCache.justConsume(r, null, true)
|
||||
|
||||
assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rsvpWithoutTargetAddress_doesNotKeepTheChainAlive() {
|
||||
val r = rsvp("cand-rsvp-no-a-tag", targetDTag = null)
|
||||
LocalCache.justConsume(r, null, true)
|
||||
|
||||
assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyCache_doesNotKeepTheChainAlive() {
|
||||
assertFalse(CalendarReminderWorker.couldStillFire(emptyList(), now))
|
||||
}
|
||||
}
|
||||
+778
@@ -0,0 +1,778 @@
|
||||
/*
|
||||
* 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.playback.playerPool.MediaPlayTimeTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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 countersKeepAccumulatingAfterADrain() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 200L })
|
||||
|
||||
accountant.add("x", 5)
|
||||
accountant.flush()
|
||||
accountant.add("x", 7)
|
||||
accountant.flush()
|
||||
|
||||
assertEquals(12L, store.allDays()[200]?.get("x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hookAddsAreDrainedInPlaceAndNeverRearmTheFlushLoop() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L })
|
||||
var hookRuns = 0
|
||||
accountant.addPreFlushHook {
|
||||
hookRuns++
|
||||
accountant.add("hook.counter", 1)
|
||||
}
|
||||
|
||||
accountant.add("x", 1)
|
||||
testScheduler.advanceTimeBy(31_000)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals("the one debounced flush ran its hooks once", 1, hookRuns)
|
||||
assertEquals(1L, store.allDays()[300]?.get("hook.counter"))
|
||||
|
||||
// If hook adds re-armed the debounce, more flushes would fire and
|
||||
// the hook counter would keep growing without any real activity.
|
||||
testScheduler.advanceTimeBy(300_000)
|
||||
testScheduler.runCurrent()
|
||||
assertEquals("no self-perpetuating flush loop", 1, hookRuns)
|
||||
assertEquals(1L, store.allDays()[300]?.get("hook.counter"))
|
||||
}
|
||||
|
||||
@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<Boolean?>(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 ProcessCpuSamplerTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun accountsCpuDeltasAcrossSamples() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L })
|
||||
var cpu = 1_000L
|
||||
val sampler = ProcessCpuSampler(accountant) { cpu }
|
||||
|
||||
cpu = 1_500L
|
||||
sampler.sample()
|
||||
cpu = 1_800L
|
||||
sampler.sample()
|
||||
|
||||
val counters = accountant.allDaysIncludingLive()[1L].orEmpty()
|
||||
assertEquals(800L, counters[UsageKeys.CPU_MS])
|
||||
}
|
||||
}
|
||||
|
||||
class ForegroundTimeIntegratorTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun accountsOnlyForegroundTime() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L })
|
||||
var now = 0L
|
||||
val fg = MutableStateFlow(false)
|
||||
val integrator = ForegroundTimeIntegrator(fg, accountant) { now }
|
||||
val job = integrator.start(backgroundScope)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
// 60s in background — must not count
|
||||
now = 60_000L
|
||||
fg.value = true
|
||||
testScheduler.runCurrent()
|
||||
|
||||
// 30s in foreground — counts
|
||||
now = 90_000L
|
||||
fg.value = false
|
||||
testScheduler.runCurrent()
|
||||
|
||||
// 60s more in background, then read: still 30s
|
||||
now = 150_000L
|
||||
val counters = accountant.allDaysIncludingLive()[1L].orEmpty()
|
||||
assertEquals(30_000L, counters[UsageKeys.APP_FG_MS])
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openForegroundSegmentIsAccountedOnRead() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L })
|
||||
var now = 0L
|
||||
val fg = MutableStateFlow(true)
|
||||
val integrator = ForegroundTimeIntegrator(fg, accountant) { now }
|
||||
val job = integrator.start(backgroundScope)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
now = 45_000L
|
||||
val counters = accountant.allDaysIncludingLive()[1L].orEmpty()
|
||||
assertEquals(45_000L, counters[UsageKeys.APP_FG_MS])
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
class RadioBurstEstimatorTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun countsBurstsOnlyAfterRadioIdleGaps() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L })
|
||||
var now = 100_000L
|
||||
val estimator =
|
||||
RadioBurstEstimator(
|
||||
accountant = accountant,
|
||||
isMobile = { true },
|
||||
isForeground = { false },
|
||||
nowMs = { now },
|
||||
)
|
||||
|
||||
estimator.onHttpActivity() // first activity = one burst
|
||||
now += 2_000
|
||||
estimator.onHttpActivity() // 2s later: same burst
|
||||
now += RadioBurstEstimator.BURST_GAP_MS + 1
|
||||
estimator.onHttpActivity() // >10s of silence: new burst
|
||||
now += 500
|
||||
estimator.onHttpActivity() // same burst
|
||||
|
||||
val counters = accountant.allDaysIncludingLive()[1L].orEmpty()
|
||||
assertEquals(2L, counters[UsageKeys.radioBursts(mobile = true, foreground = false)])
|
||||
}
|
||||
}
|
||||
|
||||
class LoopbackExclusionTest {
|
||||
@Test
|
||||
fun loopbackHostsAreRecognizedAndRealHostsAreNot() {
|
||||
assertTrue(UsageCountingInterceptor.isLoopback("127.0.0.1"))
|
||||
assertTrue(UsageCountingInterceptor.isLoopback("127.4.5.6"))
|
||||
assertTrue(UsageCountingInterceptor.isLoopback("localhost"))
|
||||
assertTrue(UsageCountingInterceptor.isLoopback("::1"))
|
||||
assertFalse(UsageCountingInterceptor.isLoopback("relay.example.com"))
|
||||
assertFalse(UsageCountingInterceptor.isLoopback("192.168.1.10"))
|
||||
assertFalse(UsageCountingInterceptor.isLoopback("128.0.0.1"))
|
||||
}
|
||||
}
|
||||
|
||||
class MediaPlayTimeTrackerTest {
|
||||
@Test
|
||||
fun accumulatesOnlyWhilePlaying() {
|
||||
var now = 0L
|
||||
var played = 0L
|
||||
val tracker = MediaPlayTimeTracker(onPlayed = { played += it }, nowMs = { now })
|
||||
|
||||
tracker.onIsPlayingChanged(true)
|
||||
now = 30_000L
|
||||
tracker.onIsPlayingChanged(false)
|
||||
|
||||
now = 100_000L // paused time must not count
|
||||
tracker.onIsPlayingChanged(true)
|
||||
now = 105_000L
|
||||
tracker.onIsPlayingChanged(false)
|
||||
tracker.onIsPlayingChanged(false) // duplicate stop is a no-op
|
||||
|
||||
assertEquals(35_000L, played)
|
||||
}
|
||||
}
|
||||
|
||||
class SessionTimeIntegratorTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun accountsActiveTimeAndCountsActivations() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L })
|
||||
var clock = 0L
|
||||
val session = SessionTimeIntegrator(accountant, "s.ms", "s.starts", nowMs = { clock })
|
||||
|
||||
session.setActive(true)
|
||||
clock += 5_000
|
||||
session.setActive(false)
|
||||
clock += 60_000 // idle time must not count
|
||||
session.setActive(true)
|
||||
clock += 1_000
|
||||
session.setActive(false)
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(6_000L, today["s.ms"])
|
||||
assertEquals(2L, today["s.starts"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedActivationsDoNotDoubleCountStarts() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L })
|
||||
var clock = 0L
|
||||
val session = SessionTimeIntegrator(accountant, "s.ms", "s.starts", nowMs = { clock })
|
||||
|
||||
session.setActive(true)
|
||||
clock += 1_000
|
||||
session.setActive(true)
|
||||
clock += 1_000
|
||||
session.setActive(false)
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(2_000L, today["s.ms"])
|
||||
assertEquals(1L, today["s.starts"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openSegmentIsAccountedOnFlushWithoutClosing() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L })
|
||||
var clock = 0L
|
||||
val session = SessionTimeIntegrator(accountant, "s.ms", nowMs = { clock })
|
||||
session.registerFlushHook()
|
||||
|
||||
session.setActive(true)
|
||||
clock += 120_000
|
||||
val mid = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals("multi-hour stable sessions account without a transition", 120_000L, mid["s.ms"])
|
||||
|
||||
clock += 30_000
|
||||
session.setActive(false)
|
||||
val end = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(150_000L, end["s.ms"])
|
||||
}
|
||||
}
|
||||
|
||||
class BatteryDrainSamplerTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private fun harness(scope: CoroutineScope): Triple<ResourceUsageAccountant, BatteryDrainSampler, Controls> {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, scope, epochDay = { 100L })
|
||||
val controls = Controls()
|
||||
val sampler =
|
||||
BatteryDrainSampler(
|
||||
accountant = accountant,
|
||||
capacityPct = { controls.pct },
|
||||
isCharging = { controls.charging },
|
||||
isForeground = { controls.foreground },
|
||||
)
|
||||
return Triple(accountant, sampler, controls)
|
||||
}
|
||||
|
||||
private class Controls {
|
||||
var pct: Int? = 90
|
||||
var charging = false
|
||||
var foreground = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstSampleOnlyEstablishesTheBaseline() =
|
||||
runTest {
|
||||
val (accountant, sampler, _) = harness(backgroundScope)
|
||||
sampler.sample()
|
||||
assertNull(accountant.allDaysIncludingLive()[100]?.get(UsageKeys.BATTERY_DRAIN_FG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dischargeDropsAreAccountedByVisibility() =
|
||||
runTest {
|
||||
val (accountant, sampler, controls) = harness(backgroundScope)
|
||||
sampler.sample() // baseline 90, discharging
|
||||
controls.pct = 87
|
||||
sampler.sample() // -3 while foreground
|
||||
controls.foreground = false
|
||||
controls.pct = 85
|
||||
sampler.sample() // -2 while background
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(3L, today[UsageKeys.BATTERY_DRAIN_FG])
|
||||
assertEquals(2L, today[UsageKeys.BATTERY_DRAIN_BG])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalsTouchingAChargerAreSkipped() =
|
||||
runTest {
|
||||
val (accountant, sampler, controls) = harness(backgroundScope)
|
||||
sampler.sample() // baseline 90, discharging
|
||||
controls.charging = true
|
||||
controls.pct = 80 // weird drop while charging: ignore
|
||||
sampler.sample()
|
||||
controls.charging = false
|
||||
controls.pct = 78 // first discharging interval after charging: baseline only
|
||||
sampler.sample()
|
||||
controls.pct = 77
|
||||
sampler.sample() // clean discharging interval: -1
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(1L, today[UsageKeys.BATTERY_DRAIN_FG])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLevelIncreaseResetsTheBaselineWithoutAccounting() =
|
||||
runTest {
|
||||
val (accountant, sampler, controls) = harness(backgroundScope)
|
||||
sampler.sample() // baseline 90
|
||||
controls.pct = 95 // e.g. charged while the process was frozen
|
||||
sampler.sample()
|
||||
controls.pct = 94
|
||||
sampler.sample() // -1
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(1L, today[UsageKeys.BATTERY_DRAIN_FG])
|
||||
}
|
||||
}
|
||||
|
||||
class MeteringNostrSignerTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
/** Pure-Kotlin fake so the test never touches secp256k1 JNI. */
|
||||
private class FakeSigner : NostrSigner("aa".repeat(32)) {
|
||||
override fun isWriteable() = true
|
||||
|
||||
override suspend fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T = throw UnsupportedOperationException("fake")
|
||||
|
||||
override suspend fun nip04Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
) = "enc04"
|
||||
|
||||
override suspend fun nip04Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
) = "dec04"
|
||||
|
||||
override suspend fun nip44Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
) = "enc44"
|
||||
|
||||
override suspend fun nip44Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
) = "dec44"
|
||||
|
||||
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw UnsupportedOperationException("fake")
|
||||
|
||||
override suspend fun deriveKey(nonce: HexKey): HexKey = "bb".repeat(32)
|
||||
|
||||
override suspend fun signPsbt(psbtHex: String): String = psbtHex
|
||||
|
||||
override fun hasForegroundSupport() = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countsSignsAndCryptoOpsWithoutChangingResults() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L })
|
||||
val metered = MeteringNostrSigner(FakeSigner(), accountant)
|
||||
|
||||
assertEquals("dec44", metered.nip44Decrypt("x", "aa".repeat(32)))
|
||||
assertEquals("dec04", metered.nip04Decrypt("x", "aa".repeat(32)))
|
||||
assertEquals("enc44", metered.nip44Encrypt("x", "aa".repeat(32)))
|
||||
runCatching { metered.sign<Event>(0L, 1, arrayOf(), "hello") }
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(2L, today[UsageKeys.DECRYPT_COUNT])
|
||||
assertEquals(1L, today[UsageKeys.ENCRYPT_COUNT])
|
||||
assertEquals(1L, today[UsageKeys.signs(UsageKeys.SIGNER_LOCAL)])
|
||||
assertNull("non-local signers must not pollute crypto CPU time", today[UsageKeys.DECRYPT_US])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun innermostSignerUnwrapsTheMeteringDecorator() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L })
|
||||
val raw = FakeSigner()
|
||||
val metered = MeteringNostrSigner(raw, accountant)
|
||||
assertEquals(raw, metered.innermostSigner())
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenTimeIntegratorTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun routeNamesLoseTheirArgumentsBeforeAnythingIsRecorded() {
|
||||
assertEquals("Profile", ScreenTimeIntegrator.screenNameOf("com.vitorpamplona.amethyst.ui.navigation.routes.Route.Profile/{userId}"))
|
||||
assertEquals("Hashtag", ScreenTimeIntegrator.screenNameOf("routes.Route.Hashtag/{tag}?extra={extra}"))
|
||||
assertEquals("Home", ScreenTimeIntegrator.screenNameOf("routes.Route.Home"))
|
||||
assertNull(ScreenTimeIntegrator.screenNameOf(null))
|
||||
assertNull(ScreenTimeIntegrator.screenNameOf(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsScreenTimeOnlyWhileForeground() =
|
||||
runTest {
|
||||
val store = ResourceUsageStore(File(temp.root, "u.json"))
|
||||
val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L })
|
||||
var clock = 0L
|
||||
val isForeground = MutableStateFlow(true)
|
||||
val integrator = ScreenTimeIntegrator(accountant, nowMs = { clock })
|
||||
integrator.start(backgroundScope, isForeground)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
integrator.onScreen("Home")
|
||||
testScheduler.runCurrent()
|
||||
clock += 5_000
|
||||
integrator.onScreen("Video")
|
||||
testScheduler.runCurrent()
|
||||
clock += 3_000
|
||||
isForeground.value = false // backgrounded on Video: segment closes
|
||||
testScheduler.runCurrent()
|
||||
clock += 60_000 // background time must not count
|
||||
isForeground.value = true
|
||||
testScheduler.runCurrent()
|
||||
clock += 2_000
|
||||
integrator.onScreen(null)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
val today = accountant.allDaysIncludingLive()[100].orEmpty()
|
||||
assertEquals(5_000L, today[UsageKeys.screenMs("Home")])
|
||||
assertEquals(5_000L, today[UsageKeys.screenMs("Video")])
|
||||
}
|
||||
}
|
||||
|
||||
class UsageSummaryMapsTest {
|
||||
@Test
|
||||
fun screenTimeAndCellularMapsAreExtractedFromCounters() {
|
||||
val s =
|
||||
UsageSummary.from(
|
||||
mapOf(
|
||||
UsageKeys.screenMs("Home") to 10_000L,
|
||||
UsageKeys.screenMs("Video") to 5_000L,
|
||||
UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 100L,
|
||||
UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 900L,
|
||||
),
|
||||
)
|
||||
assertEquals(10_000L, s.screenTimeMs["Home"])
|
||||
assertEquals(5_000L, s.screenTimeMs["Video"])
|
||||
assertEquals(100L, s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE])
|
||||
assertEquals(1_000L, s.bytesPerSubsystem[UsageKeys.ROLE_IMAGE])
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceUsageAlertsTest {
|
||||
private fun day(vararg counters: Pair<String, Long>) = mapOf(*counters)
|
||||
|
||||
@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 reconnectChurnAlerts() {
|
||||
val days =
|
||||
mapOf(
|
||||
9L to
|
||||
day(
|
||||
UsageKeys.relayConnects(mobile = true, foreground = false) to 3_000L,
|
||||
UsageKeys.relayConnects(mobile = false, foreground = true) to
|
||||
ResourceUsageAlerts.RELAY_CONNECTS_PER_DAY - 2_000L,
|
||||
),
|
||||
)
|
||||
val alert = ResourceUsageAlerts.evaluate(days, today = 10L)
|
||||
assertEquals(ResourceUsageAlerts.Reason.RECONNECT_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))
|
||||
}
|
||||
}
|
||||
+16
@@ -60,6 +60,22 @@ class ScheduledPostStoreTest {
|
||||
createdAtSec = 500,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stalePublishingClaimsAreReleasedOnLoad() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "a"))
|
||||
val claimed = store.claimDuePosts(nowSec = 2_000)
|
||||
assertEquals(1, claimed.size)
|
||||
assertEquals(ScheduledPostStatus.PUBLISHING, store.list().single().status)
|
||||
|
||||
// A fresh store (new process) finds the on-disk PUBLISHING row: the
|
||||
// worker that claimed it died with the old process, so the claim
|
||||
// must be released for the next cycle to retry.
|
||||
val reloaded = newStore().list().single()
|
||||
assertEquals(ScheduledPostStatus.PENDING, reloaded.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun add_persists_to_disk() =
|
||||
runTest {
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* 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.scheduledposts
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The gate must keep the periodic worker enqueued exactly while a PENDING
|
||||
* post exists — and, critically, must never act on the store flow's empty
|
||||
* placeholder value before the disk load completes (which would cancel
|
||||
* scheduled work an existing pending post still needs).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ScheduledPostWorkGateTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private lateinit var file: File
|
||||
|
||||
/** Chronological record of gate decisions: true = schedule, false = cancel. */
|
||||
private val decisions = mutableListOf<Boolean>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
file = File(temp.root, "scheduled_posts.json")
|
||||
decisions.clear()
|
||||
}
|
||||
|
||||
private fun newStore() = ScheduledPostStore(file)
|
||||
|
||||
private fun TestScope.startGate(store: ScheduledPostStore) =
|
||||
ScheduledPostWorkGate(
|
||||
store = store,
|
||||
scope = backgroundScope,
|
||||
onPendingWork = { decisions.add(true) },
|
||||
onNoPendingWork = { decisions.add(false) },
|
||||
).start()
|
||||
|
||||
private fun samplePost(
|
||||
id: String = "id-1",
|
||||
publishAtSec: Long = 4_000_000_000,
|
||||
) = ScheduledPost(
|
||||
id = id,
|
||||
accountPubkey = "pk1",
|
||||
signedEventJson = "{}",
|
||||
relayUrls = listOf("wss://relay.example/"),
|
||||
extraEventsJson = emptyList(),
|
||||
publishAtSec = publishAtSec,
|
||||
createdAtSec = 500,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun emptyStore_cancelsOnceAndOnlyOnce() =
|
||||
runTest {
|
||||
startGate(newStore())
|
||||
runCurrent()
|
||||
assertEquals(listOf(false), decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingPostOnDisk_schedulesWithoutSpuriousCancelFirst() =
|
||||
runTest {
|
||||
// Persist a pending post in a previous "process".
|
||||
runBlocking { newStore().add(samplePost()) }
|
||||
|
||||
// Fresh store (flow starts as the empty placeholder). The gate must
|
||||
// load from disk before collecting: the FIRST decision must be
|
||||
// "schedule", never a cancel from the placeholder value.
|
||||
startGate(newStore())
|
||||
runCurrent()
|
||||
assertEquals(listOf(true), decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addThenDrainThenAddAgain_togglesTheWorker() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
startGate(store)
|
||||
runCurrent()
|
||||
assertEquals(listOf(false), decisions)
|
||||
|
||||
store.add(samplePost(id = "a"))
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true), decisions)
|
||||
|
||||
// A second pending post must not re-fire (distinctUntilChanged).
|
||||
store.add(samplePost(id = "b"))
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true), decisions)
|
||||
|
||||
// Draining both pending posts cancels the periodic chain.
|
||||
store.markSent("a")
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true), decisions)
|
||||
store.markFailed("b", "boom")
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true, false), decisions)
|
||||
|
||||
// A new post re-schedules.
|
||||
store.add(samplePost(id = "c"))
|
||||
runCurrent()
|
||||
assertEquals(listOf(false, true, false, true), decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimingTheLastPendingPost_doesNotCancelTheRunningWorker() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "a", publishAtSec = 1_000))
|
||||
startGate(store)
|
||||
runCurrent()
|
||||
assertEquals(listOf(true), decisions)
|
||||
|
||||
// The periodic worker claims the post (PENDING -> PUBLISHING) and
|
||||
// is now mid-publish. Cancelling here would kill the very worker
|
||||
// holding the claim and strand the post in PUBLISHING forever.
|
||||
store.claimDuePosts(nowSec = 2_000)
|
||||
runCurrent()
|
||||
assertEquals("a claim must never cancel the chain", listOf(true), decisions)
|
||||
|
||||
// Only the publish actually finishing drains the gate.
|
||||
store.markSent("a")
|
||||
runCurrent()
|
||||
assertEquals(listOf(true, false), decisions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishNowOnFailedPost_reschedulesTheWorker() =
|
||||
runTest {
|
||||
val store = newStore()
|
||||
store.add(samplePost(id = "a"))
|
||||
startGate(store)
|
||||
runCurrent()
|
||||
store.markFailed("a", "relay down")
|
||||
runCurrent()
|
||||
assertEquals(listOf(true, false), decisions)
|
||||
|
||||
// Retry flips the post back to PENDING; the worker must come back.
|
||||
store.publishNow("a")
|
||||
runCurrent()
|
||||
assertEquals(listOf(true, false, true), decisions)
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -107,7 +108,12 @@ class NostrClient(
|
||||
// new filters will be sent to the relays and a potential reconnect can
|
||||
// be triggered.
|
||||
// Default: STARTS active
|
||||
private var isActive = true
|
||||
//
|
||||
// A StateFlow (not a plain Boolean) so the keep-alive loop below can
|
||||
// suspend without any scheduled timer while the client is inactive —
|
||||
// e.g. the whole time the app sits in the background after the host
|
||||
// calls [disconnect].
|
||||
private val isActiveFlow = MutableStateFlow(true)
|
||||
|
||||
/**
|
||||
* Whatches for any changes in the relay list from subscriptions or outbox
|
||||
@@ -132,16 +138,16 @@ class NostrClient(
|
||||
|
||||
// Reconnects all relays that may have disconnected
|
||||
override fun connect() {
|
||||
isActive = true
|
||||
isActiveFlow.value = true
|
||||
relayPool.connect()
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
isActive = false
|
||||
isActiveFlow.value = false
|
||||
relayPool.disconnect()
|
||||
}
|
||||
|
||||
override fun isActive() = isActive
|
||||
override fun isActive() = isActiveFlow.value
|
||||
|
||||
class Reconnect(
|
||||
val onlyIfChanged: Boolean,
|
||||
@@ -173,12 +179,19 @@ class NostrClient(
|
||||
* error code) would stay disconnected forever in the absence of any
|
||||
* subscription change. The per-relay [BasicRelayClient] backoff still
|
||||
* gates the actual reconnect attempt, so dead relays are not hammered.
|
||||
*
|
||||
* While the client is inactive (the host called [disconnect], e.g. the
|
||||
* app moved to the background) the loop suspends on [isActiveFlow]
|
||||
* instead of ticking: no timer fires at all until [connect] flips the
|
||||
* flag back, saving periodic CPU wake-ups for the whole background
|
||||
* stretch.
|
||||
*/
|
||||
private val keepAliveJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
isActiveFlow.first { it }
|
||||
delay(KEEP_ALIVE_INTERVAL_MS)
|
||||
if (this@NostrClient.isActive) {
|
||||
if (this@NostrClient.isActive()) {
|
||||
relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false)
|
||||
}
|
||||
}
|
||||
@@ -202,7 +215,7 @@ class NostrClient(
|
||||
) {
|
||||
val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener)
|
||||
|
||||
if (isActive) {
|
||||
if (isActive()) {
|
||||
activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
|
||||
if (cmd is CloseCmd || cmd is AuthCmd) {
|
||||
relayPool.sendIfConnected(relay, cmd)
|
||||
@@ -225,7 +238,7 @@ class NostrClient(
|
||||
) {
|
||||
val relaysToUpdate = activeCounts.addOrUpdate(subId, filters)
|
||||
|
||||
if (isActive) {
|
||||
if (isActive()) {
|
||||
activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
|
||||
if (cmd is CloseCmd || cmd is AuthCmd) {
|
||||
relayPool.sendIfConnected(relay, cmd)
|
||||
@@ -245,7 +258,7 @@ class NostrClient(
|
||||
) {
|
||||
val relaysToUpdate = eventOutbox.markAsSending(event, relayList)
|
||||
|
||||
if (isActive) {
|
||||
if (isActive()) {
|
||||
eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync)
|
||||
|
||||
// wakes up all the other relays
|
||||
@@ -257,14 +270,14 @@ class NostrClient(
|
||||
val relaysToUpdateReqs = activeRequests.remove(subId)
|
||||
val relaysToUpdateCounts = activeCounts.remove(subId)
|
||||
|
||||
if (isActive) {
|
||||
if (isActive()) {
|
||||
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected)
|
||||
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncFilters(relay: IRelayClient) {
|
||||
if (isActive) {
|
||||
if (isActive()) {
|
||||
scope.launch {
|
||||
activeRequests.syncState(relay.url, relay::sendOrConnectAndSync)
|
||||
activeCounts.syncState(relay.url, relay::sendOrConnectAndSync)
|
||||
@@ -337,7 +350,7 @@ class NostrClient(
|
||||
// The reconnect path is debounced and goes through
|
||||
// reconnectIfNeedsTo, which respects each relay's exponential
|
||||
// backoff so we don't hammer dead relays.
|
||||
if (isActive && relay.url in allRelays.value) {
|
||||
if (isActive() && relay.url in allRelays.value) {
|
||||
reconnect(onlyIfChanged = true)
|
||||
}
|
||||
}
|
||||
|
||||
+154
@@ -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.quartz.nip01Core.relay.client
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebsocketBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Behavioral contract of the keep-alive sweep after it was changed to suspend
|
||||
* on the client's active-state flow instead of ticking unconditionally:
|
||||
*
|
||||
* 1. while ACTIVE, a relay closed by the server is redialed within one 60s
|
||||
* sweep interval (once the per-relay backoff gate opens);
|
||||
* 2. while INACTIVE (host called [NostrClient.disconnect], i.e. the app went
|
||||
* to the background), no amount of elapsed time produces a dial;
|
||||
* 3. after [NostrClient.connect] reactivates the client, redialing works
|
||||
* again — a bug in the suspend/resume logic would leave every
|
||||
* server-dropped relay disconnected forever.
|
||||
*
|
||||
* Coroutine timers (keep-alive 60s, reconnect debounce 200ms) run on the test
|
||||
* scheduler's virtual clock; the per-relay backoff gate compares real wall
|
||||
* seconds (integer granularity), hence the short real sleeps before each
|
||||
* expected redial.
|
||||
*
|
||||
* Harness note: every socket the client dials must eventually be driven to
|
||||
* onOpen/onClosed — a [FakeWebsocketBuilder] socket that is never completed
|
||||
* leaves the relay in "connecting" forever and blocks all later dials, which
|
||||
* is exactly what a production dial never does. [settleDisconnected] flushes
|
||||
* pending debounced reconnects and completes any socket they may open.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class NostrClientKeepAliveTest {
|
||||
private val url = NormalizedRelayUrl("wss://keepalive.example.com")
|
||||
|
||||
/**
|
||||
* Flushes pending sub-second timers (the 200ms reconnect debounce) and,
|
||||
* if a flush dialed a new socket, completes its lifecycle so the relay
|
||||
* ends DISCONNECTED with no pending timers besides the keep-alive sweep.
|
||||
*/
|
||||
private fun TestScope.settleDisconnected(builder: FakeWebsocketBuilder) {
|
||||
repeat(4) {
|
||||
val before = builder.connectAttempts
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
if (builder.connectAttempts == before) return
|
||||
builder.lastListener.onOpen(50, false)
|
||||
builder.lastListener.onClosed(1000, "test settle")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepAliveSweepPausesWhileInactiveAndResumesAfterReconnect() =
|
||||
runTest {
|
||||
val builder = FakeWebsocketBuilder()
|
||||
val client = NostrClient(builder, this)
|
||||
try {
|
||||
// Flush refreshConnection's initial emission against the
|
||||
// still-empty pool so it can't dial later.
|
||||
advanceTimeBy(500)
|
||||
runCurrent()
|
||||
|
||||
// Subscribing dials the relay immediately.
|
||||
client.subscribe("sub", mapOf(url to listOf(Filter())))
|
||||
assertEquals(1, builder.connectAttempts)
|
||||
|
||||
// Server closes the established connection. While ACTIVE the
|
||||
// client must redial within one keep-alive interval.
|
||||
builder.lastListener.onOpen(50, false)
|
||||
builder.lastListener.onClosed(1000, "server closed")
|
||||
settleDisconnected(builder)
|
||||
val beforeActiveSweep = builder.connectAttempts
|
||||
Thread.sleep(3_500)
|
||||
advanceTimeBy(61_000)
|
||||
runCurrent()
|
||||
assertTrue(
|
||||
builder.connectAttempts > beforeActiveSweep,
|
||||
"active client should redial a server-closed relay within one sweep, " +
|
||||
"got ${builder.connectAttempts} attempts (baseline $beforeActiveSweep)",
|
||||
)
|
||||
|
||||
// Leave the relay cleanly disconnected, then deactivate.
|
||||
builder.lastListener.onOpen(50, false)
|
||||
builder.lastListener.onClosed(1000, "server closed")
|
||||
settleDisconnected(builder)
|
||||
client.disconnect()
|
||||
val whileInactiveBaseline = builder.connectAttempts
|
||||
|
||||
// The relay's backoff gate is reset by disconnect(), so any
|
||||
// stray sweep tick WOULD dial — this fails if the sweep keeps
|
||||
// running (or anything else dials) while inactive.
|
||||
Thread.sleep(3_500)
|
||||
advanceTimeBy(30 * 60_000)
|
||||
runCurrent()
|
||||
assertEquals(
|
||||
whileInactiveBaseline,
|
||||
builder.connectAttempts,
|
||||
"no dial may happen while the client is inactive",
|
||||
)
|
||||
|
||||
// Reactivation dials the pool immediately...
|
||||
client.connect()
|
||||
runCurrent()
|
||||
assertEquals(
|
||||
whileInactiveBaseline + 1,
|
||||
builder.connectAttempts,
|
||||
"connect() should redial the pool immediately",
|
||||
)
|
||||
|
||||
// ...and after the pause a server-closed relay must again be
|
||||
// redialed within one sweep — fails if the sweep never
|
||||
// resumes after the inactive stretch.
|
||||
builder.lastListener.onOpen(50, false)
|
||||
builder.lastListener.onClosed(1000, "server closed")
|
||||
settleDisconnected(builder)
|
||||
val beforeResumedSweep = builder.connectAttempts
|
||||
Thread.sleep(3_500)
|
||||
advanceTimeBy(61_000)
|
||||
runCurrent()
|
||||
assertTrue(
|
||||
builder.connectAttempts > beforeResumedSweep,
|
||||
"sweep did not resume after connect(); a server-dropped relay would stay disconnected forever",
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user