mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
This commit is contained in:
@@ -44,7 +44,7 @@ jobs:
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -101,6 +101,25 @@ jobs:
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
|
||||
# (runtime-version is greped from the manifest so this never drifts).
|
||||
# Retried: the runtime download from Flathub is ~1 GB and flatpak
|
||||
# install resumes cleanly on re-run.
|
||||
- name: Install Flatpak tooling + runtimes (linux-portable only)
|
||||
if: matrix.family == 'linux-portable'
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
with:
|
||||
max_attempts: 3
|
||||
timeout_minutes: 15
|
||||
command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update && sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
FDO_VER=$(grep -E "^runtime-version:" desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml | cut -d"'" -f2)
|
||||
flatpak install --user --noninteractive flathub \
|
||||
"org.freedesktop.Platform//${FDO_VER}" \
|
||||
"org.freedesktop.Sdk//${FDO_VER}"
|
||||
|
||||
# macOS only: import the Developer ID Application cert into a throwaway
|
||||
# keychain so jpackage's codesign pass can find it. Soft — if the
|
||||
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
|
||||
@@ -161,6 +180,36 @@ jobs:
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
|
||||
fi
|
||||
|
||||
# Flatpak bundle: wraps the same createReleaseDistributable tree the
|
||||
# AppImage uses. The manifest (desktopApp/packaging/flatpak/) copies the
|
||||
# prebuilt jpackage tree into /app — no Gradle runs inside the sandbox.
|
||||
# build-bundle emits a single-file .flatpak whose baked-in runtime-repo
|
||||
# lets the user's flatpak fetch the freedesktop runtime from Flathub on
|
||||
# install. --disable-rofiles-fuse: GH runners lack a usable rofiles-fuse.
|
||||
- name: Build Flatpak bundle (linux-portable only)
|
||||
if: matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
PKG="desktopApp/packaging/flatpak"
|
||||
APP_ID="com.vitorpamplona.amethyst.Desktop"
|
||||
OUT="desktopApp/build/flatpak"
|
||||
# Inject the AppStream <release> entry for this build (the checked-in
|
||||
# metainfo deliberately carries none — CI is the source of truth).
|
||||
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
|
||||
"${PKG}/${APP_ID}.metainfo.xml"
|
||||
mkdir -p "$OUT"
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--state-dir="${OUT}/.flatpak-builder" \
|
||||
--repo="${OUT}/repo" \
|
||||
"${OUT}/build-dir" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
flatpak build-bundle "${OUT}/repo" \
|
||||
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
|
||||
"$APP_ID" \
|
||||
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
ls -la "$OUT"
|
||||
|
||||
- name: Collect + rename assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -180,6 +180,11 @@ desktopApp/src/jvmMain/appResources/*/ffmpeg/*
|
||||
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
desktopApp/packaging/appimage/squashfs-root/
|
||||
|
||||
# flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/)
|
||||
.flatpak-builder/
|
||||
desktopApp/packaging/flatpak/**/build-dir/
|
||||
desktopApp/packaging/flatpak/**/repo/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
|
||||
+14
-2
@@ -37,7 +37,9 @@ Platform-specific:
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
[`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md))
|
||||
|
||||
Install Linux RPM tooling:
|
||||
|
||||
@@ -109,6 +111,7 @@ are **not** required to build Amethyst from the committed sources.
|
||||
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
|
||||
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
|
||||
| Windows `.zip` portable | See below (inline `7z`) | — |
|
||||
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
|
||||
|
||||
@@ -148,7 +151,7 @@ Where:
|
||||
| `<version>` | Tag stripped of leading `vX.YY.ZZ` |
|
||||
| `<family>` | `macos`, `windows`, `linux` |
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` |
|
||||
|
||||
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
|
||||
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
|
||||
@@ -160,6 +163,7 @@ Examples:
|
||||
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.12.1-windows-x64.msi`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.flatpak`
|
||||
|
||||
---
|
||||
|
||||
@@ -625,12 +629,18 @@ State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb,
|
||||
expose downgrade migration risks — **prefer a single install channel per
|
||||
machine**.
|
||||
|
||||
**Exception: Flatpak.** The sandbox redirects XDG dirs into
|
||||
`~/.var/app/com.vitorpamplona.amethyst.Desktop/`, so a Flatpak install keeps
|
||||
its own separate state and does not see (or risk downgrading) state written
|
||||
by any other channel.
|
||||
|
||||
| OS | App location | State directories |
|
||||
|---|---|---|
|
||||
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
|
||||
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
|
||||
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
|
||||
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
|
||||
| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` |
|
||||
|
||||
Uninstall:
|
||||
|
||||
@@ -639,6 +649,8 @@ Uninstall:
|
||||
- .deb: `sudo apt remove amethyst`
|
||||
- .rpm: `sudo dnf remove amethyst`
|
||||
- AppImage / tar.gz: delete the file / extracted directory
|
||||
- Flatpak: `flatpak uninstall com.vitorpamplona.amethyst.Desktop` (add
|
||||
`--delete-data` to also remove `~/.var/app/…`)
|
||||
- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually
|
||||
|
||||
---
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -82,10 +82,12 @@ class AntiSpamFilter {
|
||||
(recentAddressables[hash] != null && recentAddressables[hash] != address) ||
|
||||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address))
|
||||
) {
|
||||
// may be null if the first duplicate was evicted from the LRU cache
|
||||
// while the spammer record still matches this hash.
|
||||
val existingAddress = recentAddressables[hash]
|
||||
|
||||
val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
|
||||
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
|
||||
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
@@ -111,8 +113,10 @@ class AntiSpamFilter {
|
||||
(existingEvent != null && existingEvent != event.id) ||
|
||||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
|
||||
) {
|
||||
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
|
||||
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
|
||||
// existingEvent may be null if the first duplicate was evicted from the
|
||||
// LRU cache while the spammer record still matches this hash.
|
||||
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
|
||||
|
||||
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
|
||||
|
||||
@@ -149,12 +153,12 @@ class AntiSpamFilter {
|
||||
Spammer(
|
||||
pubkeyHex = event.pubKey,
|
||||
duplicatedEventIds = setOf(),
|
||||
duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()),
|
||||
duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()),
|
||||
)
|
||||
} else {
|
||||
Spammer(
|
||||
pubkeyHex = event.pubKey,
|
||||
duplicatedEventIds = setOf(recentEventIds[hashCode], event.id),
|
||||
duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id),
|
||||
duplicatedEventAddresses = setOf(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3276,10 +3276,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) {
|
||||
@@ -3290,7 +3308,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()
|
||||
}
|
||||
|
||||
|
||||
+7
-19
@@ -18,23 +18,11 @@
|
||||
* 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.commons.ui.elements
|
||||
package com.vitorpamplona.amethyst.service.crashreports
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun BoostedMark() {
|
||||
Text(
|
||||
"Boosted",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -38,6 +38,8 @@ private data class ScrollState(
|
||||
|
||||
object ScrollStateKeys {
|
||||
const val NOTIFICATION_SCREEN = "NotificationsFeed"
|
||||
const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel"
|
||||
const val NOTIFICATION_SIDE_PANEL_FOLLOWING = "NotificationsSidePanelFollowing"
|
||||
const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed"
|
||||
const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed"
|
||||
const val VIDEO_SCREEN = "VideoFeed"
|
||||
|
||||
+21
-5
@@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -80,10 +83,14 @@ fun DisappearingScaffold(
|
||||
) {
|
||||
val state = rememberDisappearingBarState()
|
||||
|
||||
// Large screens (rail / permanent drawer) pin the chrome: bars never slide away on
|
||||
// scroll, and the immersive status-bar hiding stays off.
|
||||
val canHideBars = allowBarHide && !LocalScreenLayout.current.isLargeScreen
|
||||
|
||||
// Hold the latest values in state so the NSC's captured lambda stays fresh across
|
||||
// recompositions without rebuilding the NSC itself.
|
||||
val latestIsActive by rememberUpdatedState(isActive)
|
||||
val latestAllowBarHide by rememberUpdatedState(allowBarHide)
|
||||
val latestAllowBarHide by rememberUpdatedState(canHideBars)
|
||||
val latestAccountViewModel by rememberUpdatedState(accountViewModel)
|
||||
|
||||
val connection =
|
||||
@@ -100,24 +107,33 @@ fun DisappearingScaffold(
|
||||
}
|
||||
|
||||
// Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars.
|
||||
if (allowBarHide) {
|
||||
if (canHideBars) {
|
||||
ResetBarsOnResume(state)
|
||||
ImmersiveStatusBarEffect(state)
|
||||
}
|
||||
|
||||
// If the bars were scrolled away when hiding got disabled (e.g. the window grew to a
|
||||
// large tier mid-scroll), nothing above can bring them back — the nested-scroll
|
||||
// connection and the resume reset are gone. Snap them visible here instead of
|
||||
// leaving the chrome stranded off-screen.
|
||||
LaunchedEffect(canHideBars, state) {
|
||||
if (!canHideBars) state.resetToVisible()
|
||||
}
|
||||
|
||||
// When bars are pinned, skip attaching the nested-scroll connection entirely.
|
||||
// The outer Surface provides the Material container color + onBackground as
|
||||
// LocalContentColor, matching M3 Scaffold's behaviour (without it, default text
|
||||
// color falls back to Color.Black and is invisible on the dark theme).
|
||||
val baseModifier =
|
||||
if (allowBarHide) {
|
||||
if (canHideBars) {
|
||||
Modifier.imePadding().nestedScroll(connection)
|
||||
} else {
|
||||
Modifier.imePadding()
|
||||
}
|
||||
val rootModifier =
|
||||
baseModifier
|
||||
.let { if (topBar == null) it.statusBarsPadding() else it }
|
||||
// systemBars (not just statusBars) so a desktop window's caption bar is respected too.
|
||||
.let { if (topBar == null) it.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) else it }
|
||||
.let { if (bottomBar == null) it.navigationBarsPadding() else it }
|
||||
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.layouts
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
|
||||
/** How the app shell presents its top-level navigation for the current window size. */
|
||||
enum class NavigationStyle {
|
||||
/** Compact windows (phones): bottom navigation bar + modal drawer. */
|
||||
BOTTOM_BAR,
|
||||
|
||||
/**
|
||||
* Medium windows (portrait tablets, unfolded foldables): a left navigation rail
|
||||
* replaces the bottom bar; the drawer stays modal behind the rail's avatar button.
|
||||
*/
|
||||
NAV_RAIL,
|
||||
|
||||
/** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */
|
||||
PERMANENT_DRAWER,
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell layout decisions for the current window, published once per window size change
|
||||
* through [LocalScreenLayout] so every screen, bar and panel agrees on the same tier.
|
||||
*/
|
||||
@Immutable
|
||||
data class ScreenLayoutSpec(
|
||||
val navigationStyle: NavigationStyle,
|
||||
val showsNotificationPanel: Boolean,
|
||||
) {
|
||||
/**
|
||||
* True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin
|
||||
* the top/bottom chrome (no disappearing bars on scroll).
|
||||
*/
|
||||
val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR
|
||||
|
||||
companion object {
|
||||
val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone }
|
||||
|
||||
/**
|
||||
* Minimum window width for the docked notification panel: the permanent drawer
|
||||
* ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth])
|
||||
* only coexist comfortably from a landscape-tablet-sized window up.
|
||||
*/
|
||||
private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200
|
||||
|
||||
val PermanentDrawerWidth = 300.dp
|
||||
|
||||
val NotificationPanelWidth = 360.dp
|
||||
|
||||
/**
|
||||
* Maximum width of a screen's content column inside a wide center pane. Every NavHost
|
||||
* destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects),
|
||||
* so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading
|
||||
* column instead of stretching across the pane. Screens that genuinely need the full pane
|
||||
* (Messages' two-pane split, the embedded browser surfaces) opt out at registration.
|
||||
*/
|
||||
val FeedContentMaxWidth = 600.dp
|
||||
|
||||
/**
|
||||
* Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme
|
||||
* background so the gutters match the screens' own surfaces; on Compact windows the cap is
|
||||
* wider than the pane and this is a visual no-op.
|
||||
*/
|
||||
@Composable
|
||||
fun CappedScreenContent(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.widthIn(max = FeedContentMaxWidth)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
fun rememberScreenLayoutSpec(): ScreenLayoutSpec {
|
||||
val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass
|
||||
val windowWidthDp = LocalConfiguration.current.screenWidthDp
|
||||
return remember(widthSizeClass, windowWidthDp) {
|
||||
val style =
|
||||
when (widthSizeClass) {
|
||||
WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER
|
||||
WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL
|
||||
else -> NavigationStyle.BOTTOM_BAR
|
||||
}
|
||||
ScreenLayoutSpec(
|
||||
navigationStyle = style,
|
||||
showsNotificationPanel =
|
||||
style == NavigationStyle.PERMANENT_DRAWER &&
|
||||
windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -42,12 +44,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
|
||||
@@ -55,6 +61,10 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.LocalTabReselectCoordinator
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinator
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
@@ -236,6 +246,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
|
||||
@@ -285,38 +296,56 @@ fun AppNavigation(
|
||||
) {
|
||||
val nav = rememberNav()
|
||||
|
||||
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
BuildNavigation(accountViewModel, nav)
|
||||
// Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy)
|
||||
// so its favorite resolves as reliably as a pinned web app's URL — the data the embedded
|
||||
// preloader below and the full-screen launcher both need. Not API-gated: every device's
|
||||
// launcher benefits, and it's the only preload step that runs below API 30.
|
||||
FavoriteAppManifestPreloader(accountViewModel)
|
||||
// Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by
|
||||
// holding their surfaces attached. Below the drawer (drawn by the layout above) and below
|
||||
// dialogs (separate windows). API 30+ only, matching the embedded-surface feature.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
EmbeddedTabLayer(bottomBarItems.favoriteIds())
|
||||
// Warm every pinned tab at startup so the first tap is instant (content already local).
|
||||
EmbeddedTabPreloader(accountViewModel)
|
||||
// Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips
|
||||
// (an embed WebView's theme is fixed at construction, so it can't follow a live switch).
|
||||
EmbeddedTabThemeWatcher()
|
||||
// One layout decision per window size for the whole shell: bottom bar vs rail vs
|
||||
// permanent drawer, plus the docked notification panel. Every screen, bar and panel
|
||||
// below reads the same spec through LocalScreenLayout. The provider wraps this whole
|
||||
// function body so anything added to AppNavigation later is inside it by construction.
|
||||
val screenLayout = rememberScreenLayoutSpec()
|
||||
val tabReselectCoordinator = remember { TabReselectCoordinator() }
|
||||
|
||||
// Mirror the tier for the nav-transition specs, which run outside composition and so
|
||||
// can't read LocalScreenLayout (see NavTransitionTier).
|
||||
SideEffect { NavTransitionTier.isLargeScreen = screenLayout.isLargeScreen }
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalScreenLayout provides screenLayout,
|
||||
LocalTabReselectCoordinator provides tabReselectCoordinator,
|
||||
) {
|
||||
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
BuildNavigation(accountViewModel, nav)
|
||||
// Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy)
|
||||
// so its favorite resolves as reliably as a pinned web app's URL — the data the embedded
|
||||
// preloader below and the full-screen launcher both need. Not API-gated: every device's
|
||||
// launcher benefits, and it's the only preload step that runs below API 30.
|
||||
FavoriteAppManifestPreloader(accountViewModel)
|
||||
// Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by
|
||||
// holding their surfaces attached. Below the drawer (drawn by the layout above) and below
|
||||
// dialogs (separate windows). API 30+ only, matching the embedded-surface feature.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
EmbeddedTabLayer(bottomBarItems.favoriteIds())
|
||||
// Warm every pinned tab at startup so the first tap is instant (content already local).
|
||||
EmbeddedTabPreloader(accountViewModel)
|
||||
// Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips
|
||||
// (an embed WebView's theme is fixed at construction, so it can't follow a live switch).
|
||||
EmbeddedTabThemeWatcher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrackScreenTime(nav)
|
||||
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
|
||||
|
||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||
DisplayNotifyMessages(accountViewModel, nav)
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayResourceUsageAlert(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
|
||||
ObserveIncomingCalls(accountViewModel)
|
||||
}
|
||||
|
||||
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
|
||||
|
||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||
DisplayNotifyMessages(accountViewModel, nav)
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
|
||||
ObserveIncomingCalls(accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -332,6 +361,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,
|
||||
@@ -343,9 +393,9 @@ fun BuildNavigation(
|
||||
enterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(200)) },
|
||||
) {
|
||||
composable<Route.Home> { HomeScreen(accountViewModel, nav) }
|
||||
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
|
||||
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
|
||||
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
|
||||
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
@@ -362,10 +412,10 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Browser> { BrowserScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Browser>(capWidth = false) { BrowserScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WebApp> { WebAppScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NostrApp> { NostrAppScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WebApp>(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
|
||||
@@ -404,7 +454,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
||||
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||
composableCapped<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WalletSend> { WalletSendScreen(it.walletId, accountViewModel, nav) }
|
||||
@@ -457,7 +507,7 @@ fun BuildNavigation(
|
||||
composableFromBottomArgs<Route.TopUpMint> { TopUpMintScreen(it.mintUrl, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
|
||||
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
|
||||
composableCapped<Route.Search> { SearchScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
||||
@@ -489,6 +539,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)
|
||||
|
||||
+106
-19
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
@@ -33,6 +35,7 @@ import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent
|
||||
|
||||
// Per-entry hint stamped by Nav.navBottomBar marking that the entry was
|
||||
// reached via a bottom-nav tab. Used in two places:
|
||||
@@ -44,41 +47,99 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot"
|
||||
|
||||
fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) == true
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
|
||||
/**
|
||||
* The shell's current layout tier, mirrored for the transition specs below. Transition
|
||||
* lambdas run when a navigation starts — outside composition — so they can't read
|
||||
* LocalScreenLayout; AppNavigation mirrors the spec here instead.
|
||||
*
|
||||
* One navigation grammar, tier-scaled motion: phones keep full-width slides (a pushed
|
||||
* screen physically stacks on top), while large screens use short shared-axis moves —
|
||||
* content there swaps inside a persistent shell, and a full-pane slide from the right
|
||||
* reads as disconnected when the click came from the docked drawer on the left.
|
||||
*/
|
||||
object NavTransitionTier {
|
||||
@Volatile
|
||||
var isLargeScreen: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless
|
||||
* it opted out with `capWidth = false`. Every builder below routes through this, so all
|
||||
* destinations — top bars included — share the centered column on large screens by default.
|
||||
*/
|
||||
@Composable
|
||||
fun MaybeCappedScreen(
|
||||
capWidth: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (capWidth) {
|
||||
CappedScreenContent(content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Stock fade-transition destination, capped to the reading-column width on wide panes. */
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
|
||||
composable<T> { entry ->
|
||||
CappedScreenContent { content(entry) }
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
composable<T>(
|
||||
enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd },
|
||||
exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut },
|
||||
popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn },
|
||||
popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd },
|
||||
content = content,
|
||||
enterTransition = { if (targetState.isBottomNavRoot()) null else enterFromEnd() },
|
||||
exitTransition = { if (targetState.isBottomNavRoot()) null else exitBehind() },
|
||||
popEnterTransition = { if (initialState.isBottomNavRoot()) null else popEnterFromBehind() },
|
||||
popExitTransition = { if (initialState.isBottomNavRoot()) null else popExitToEnd() },
|
||||
content = { entry ->
|
||||
MaybeCappedScreen(capWidth) { content(entry) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
|
||||
composableFromEnd<T> {
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
|
||||
) {
|
||||
composableFromEnd<T>(capWidth) {
|
||||
content(it.toRoute<T>())
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
composable<T>(
|
||||
enterTransition = { slideInVerticallyFromBottom },
|
||||
exitTransition = { scaleOut },
|
||||
popEnterTransition = { scaleIn },
|
||||
popExitTransition = { slideOutVerticallyToBottom },
|
||||
content = content,
|
||||
enterTransition = { enterFromBottom() },
|
||||
exitTransition = { exitBehind() },
|
||||
popEnterTransition = { popEnterFromBehind() },
|
||||
popExitTransition = { popExitToBottom() },
|
||||
content = { entry ->
|
||||
MaybeCappedScreen(capWidth) { content(entry) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
|
||||
composableFromBottom<T> {
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
|
||||
) {
|
||||
composableFromBottom<T>(capWidth) {
|
||||
content(it.toRoute())
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
|
||||
composable<T> {
|
||||
content(it.toRoute())
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableArgs(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
|
||||
) {
|
||||
composable<T> { entry ->
|
||||
MaybeCappedScreen(capWidth) { content(entry.toRoute()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,3 +151,29 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta
|
||||
|
||||
val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f)
|
||||
val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f)
|
||||
|
||||
/** Fraction of the pane a shared-axis move travels on large screens — a nudge, not a fly-in. */
|
||||
private const val SHARED_AXIS_FRACTION = 10
|
||||
|
||||
val sharedAxisEnterFromEnd = slideInHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween())
|
||||
val sharedAxisExitToEnd = slideOutHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween())
|
||||
val sharedAxisEnterFromBottom = slideInVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween())
|
||||
val sharedAxisExitToBottom = slideOutVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween())
|
||||
|
||||
// The outgoing/incoming screen *behind* a push: on phones the pushed screen covers it, so a
|
||||
// slight scale is enough; on large screens the incoming screen fades, so the one behind must
|
||||
// fade too or both stay visible mid-transition.
|
||||
val fadeScaleOut = scaleOut + fadeOut(animationSpec = tween())
|
||||
val fadeScaleIn = scaleIn + fadeIn(animationSpec = tween())
|
||||
|
||||
fun enterFromEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromEnd else slideInHorizontallyFromEnd
|
||||
|
||||
fun popExitToEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToEnd else slideOutHorizontallyToEnd
|
||||
|
||||
fun enterFromBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromBottom else slideInVerticallyFromBottom
|
||||
|
||||
fun popExitToBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToBottom else slideOutVerticallyToBottom
|
||||
|
||||
fun exitBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleOut else scaleOut
|
||||
|
||||
fun popEnterFromBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleIn else scaleIn
|
||||
|
||||
+60
-27
@@ -36,8 +36,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -49,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -73,6 +76,22 @@ fun AppBottomBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: (Route) -> Unit,
|
||||
) {
|
||||
// Publish this screen's re-tap behavior even when the bar renders nothing: on large
|
||||
// screens the navigation rail routes reselect taps back through the coordinator so the
|
||||
// same per-screen scroll-to-top/refresh logic runs.
|
||||
val coordinator = LocalTabReselectCoordinator.current
|
||||
val latestRoute by rememberUpdatedState(selectedRoute)
|
||||
val latestOnClick by rememberUpdatedState(onClick)
|
||||
DisposableEffect(coordinator) {
|
||||
val handler: (Route) -> Unit = { latestOnClick(it) }
|
||||
coordinator.register({ latestRoute }, handler)
|
||||
onDispose { coordinator.unregister(handler) }
|
||||
}
|
||||
|
||||
// Large screens replace the bottom bar with the navigation rail (Medium) or the
|
||||
// permanently docked drawer (Expanded).
|
||||
if (LocalScreenLayout.current.isLargeScreen) return
|
||||
|
||||
// Hide the bar on entries that aren't a tab root (drawer or in-app
|
||||
// pushes). Mirrors the back-arrow rule in canPop().
|
||||
if (nav.canPop()) return
|
||||
@@ -101,6 +120,43 @@ fun AppBottomBar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the icon model for a pinned favorite: a web favorite's captured favicon (else the
|
||||
* generic globe), an nsite/napplet's verified manifest icon bundled in its own content (the
|
||||
* iframe sandbox rules out live capture; else the grid glyph). Shared by the bottom bar and
|
||||
* the navigation rail.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
|
||||
when (fav) {
|
||||
is FavoriteApp.WebApp -> {
|
||||
// Captured favicons, keyed so the icon appears once the site's capture lands.
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
remember(fav, iconKeys) {
|
||||
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
|
||||
}
|
||||
}
|
||||
|
||||
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
|
||||
}
|
||||
|
||||
/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */
|
||||
@Composable
|
||||
internal fun FavoriteEntryIcon(
|
||||
fav: FavoriteApp,
|
||||
selected: Boolean,
|
||||
iconModel: Any?,
|
||||
) {
|
||||
Box(Size27Modifier, contentAlignment = Alignment.Center) {
|
||||
FavoriteAppIcon(
|
||||
app = fav,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
|
||||
modifier = Size25Modifier,
|
||||
iconModel = iconModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBottomMenu(
|
||||
items: List<BottomBarEntry>,
|
||||
@@ -112,9 +168,6 @@ private fun RenderBottomMenu(
|
||||
// Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan.
|
||||
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
|
||||
|
||||
// Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe.
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -147,17 +200,7 @@ private fun RenderBottomMenu(
|
||||
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
|
||||
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
|
||||
}
|
||||
// A web favorite uses its captured favicon; an nsite/napplet uses the verified
|
||||
// icon blob bundled in its own content (the iframe sandbox rules out live capture).
|
||||
val iconModel =
|
||||
when (fav) {
|
||||
is FavoriteApp.WebApp ->
|
||||
remember(fav, iconKeys) {
|
||||
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
|
||||
}
|
||||
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
|
||||
}
|
||||
FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav)
|
||||
FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,18 +218,7 @@ private fun RowScope.FavoriteNavItem(
|
||||
) {
|
||||
NavigationBarItem(
|
||||
alwaysShowLabel = false,
|
||||
icon = {
|
||||
Box(Size27Modifier, contentAlignment = Alignment.Center) {
|
||||
// A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else
|
||||
// the grid glyph).
|
||||
FavoriteAppIcon(
|
||||
app = fav,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
|
||||
modifier = Size25Modifier,
|
||||
iconModel = iconModel,
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = { FavoriteEntryIcon(fav, selected, iconModel) },
|
||||
// No label — favorite tabs match the built-in items, which show icon only.
|
||||
selected = selected,
|
||||
onClick = { nav(destination) },
|
||||
@@ -216,8 +248,9 @@ private fun RowScope.HasNewItemsIcon(
|
||||
)
|
||||
}
|
||||
|
||||
/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */
|
||||
@Composable
|
||||
private fun NotifiableIcon(
|
||||
internal fun NotifiableIcon(
|
||||
selected: Boolean,
|
||||
def: NavBarItemDef,
|
||||
destination: Route,
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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.bottombars
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Medium-width windows: a left rail that carries the same user-configured destinations as the
|
||||
* phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the
|
||||
* user's customization and new-item dots carry over. The header avatar opens the modal drawer,
|
||||
* mirroring the avatar button in the phone top bars. Re-tapping the selected item routes
|
||||
* through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler.
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavigationRail(
|
||||
nav: Nav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
|
||||
|
||||
val reselectCoordinator = LocalTabReselectCoordinator.current
|
||||
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
NavigationRail(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
header = {
|
||||
// Same affordance as the phone top bars: the account avatar opens the drawer.
|
||||
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
items.forEach { entry ->
|
||||
when (entry) {
|
||||
is BottomBarEntry.BuiltIn -> {
|
||||
val def = NavBarCatalog[entry.item] ?: return@forEach
|
||||
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
|
||||
val selected = currentDestination?.hasRoute(destination::class) == true
|
||||
NavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (selected) {
|
||||
reselectCoordinator.reselect(destination)
|
||||
} else {
|
||||
nav.navBottomBar(destination)
|
||||
}
|
||||
},
|
||||
icon = { NotifiableIcon(selected, def, destination, accountViewModel) },
|
||||
)
|
||||
}
|
||||
|
||||
is BottomBarEntry.Favorite -> {
|
||||
val fav = favoritesById[entry.favoriteId] ?: return@forEach
|
||||
val destination =
|
||||
when (fav) {
|
||||
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
|
||||
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
|
||||
}
|
||||
// Favorites carry arguments (url / coordinate), so class matching alone
|
||||
// would light up every pinned app of the same kind; compare the full route.
|
||||
val selected =
|
||||
remember(navBackStackEntry, destination) {
|
||||
when (destination) {
|
||||
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination
|
||||
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
NavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (selected) {
|
||||
reselectCoordinator.reselect(destination)
|
||||
} else {
|
||||
nav.navBottomBar(destination)
|
||||
}
|
||||
},
|
||||
icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
val FABPaddingFromBottom = 30.dp
|
||||
|
||||
@Composable
|
||||
fun Modifier.fabBottomBarPadding(nav: INav): Modifier = if (nav.canPop()) padding(bottom = FABPaddingFromBottom) else this
|
||||
fun Modifier.fabBottomBarPadding(nav: INav): Modifier =
|
||||
if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) {
|
||||
// canPop entries hide the bar on phones; large screens never render it at all.
|
||||
padding(bottom = FABPaddingFromBottom)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that places [content] in a [Box] with [fabBottomBarPadding] applied.
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.bottombars
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
|
||||
/**
|
||||
* Bridges the shell-level [AppNavigationRail] to the per-screen re-tap behaviors that live in
|
||||
* each screen's [AppBottomBar] onClick lambda (scroll-to-top, feed refresh, ...).
|
||||
*
|
||||
* On phones the bottom bar invokes the screen's lambda directly. On large screens the bar
|
||||
* renders nothing, but it still registers the screen's lambda here; when the user taps the
|
||||
* rail item that is already selected, the rail routes the tap back through [reselect] so the
|
||||
* exact same per-screen logic runs.
|
||||
*/
|
||||
@Stable
|
||||
class TabReselectCoordinator {
|
||||
private var currentRoute: (() -> Route?)? = null
|
||||
private var currentHandler: ((Route) -> Unit)? = null
|
||||
|
||||
fun register(
|
||||
route: () -> Route?,
|
||||
handler: (Route) -> Unit,
|
||||
) {
|
||||
currentRoute = route
|
||||
currentHandler = handler
|
||||
}
|
||||
|
||||
/** Unregisters only if [handler] is still the active one, so a newly composed screen's
|
||||
* registration is not torn down by the outgoing screen's dispose during a transition. */
|
||||
fun unregister(handler: (Route) -> Unit) {
|
||||
if (currentHandler === handler) {
|
||||
currentHandler = null
|
||||
currentRoute = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Invokes the active tab root's handler if it owns [route]. Returns true if handled. */
|
||||
fun reselect(route: Route): Boolean {
|
||||
val handler = currentHandler ?: return false
|
||||
if (currentRoute?.invoke() != route) return false
|
||||
handler(route)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
val LocalTabReselectCoordinator = staticCompositionLocalOf { TabReselectCoordinator() }
|
||||
+90
-45
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -56,6 +57,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
|
||||
@@ -147,59 +150,93 @@ fun DrawerContent(
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val onClickUser = {
|
||||
nav.nav(routeFor(accountViewModel.userProfile()))
|
||||
nav.closeDrawer()
|
||||
}
|
||||
|
||||
ModalDrawerSheet(
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.background,
|
||||
drawerTonalElevation = 0.dp,
|
||||
) {
|
||||
DrawerContentBody(nav, openSheet, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded windows: the same drawer content, permanently docked on the left of the shell
|
||||
* instead of sliding in as a modal sheet.
|
||||
*/
|
||||
@Composable
|
||||
fun PermanentDrawerContent(
|
||||
nav: INav,
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.width(PermanentDrawerWidth).fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
Modifier.windowInsetsPadding(
|
||||
WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start),
|
||||
),
|
||||
) {
|
||||
ProfileContent(
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
accountViewModel,
|
||||
onClickUser,
|
||||
)
|
||||
|
||||
Column(drawerSpacing) {
|
||||
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav)
|
||||
}
|
||||
|
||||
FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
ListContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BottomContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
DrawerContentBody(nav, openSheet, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerContentBody(
|
||||
nav: INav,
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val onClickUser = {
|
||||
nav.nav(routeFor(accountViewModel.userProfile()))
|
||||
nav.closeDrawer()
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
ProfileContent(
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
accountViewModel,
|
||||
onClickUser,
|
||||
)
|
||||
|
||||
Column(drawerSpacing) {
|
||||
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav)
|
||||
}
|
||||
|
||||
FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
ListContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BottomContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
@@ -237,14 +274,14 @@ fun ProfileContentTemplate(
|
||||
AsyncImage(
|
||||
model = profileBanner,
|
||||
contentDescription = stringRes(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = R.drawable.profile_banner,
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
}
|
||||
@@ -392,8 +429,10 @@ fun StatusEditBar(
|
||||
|
||||
val currentStatus = remember { mutableStateOf(savedStatus ?: "") }
|
||||
|
||||
// In the docked drawer the DrawerState never opens (it stays Closed while the drawer
|
||||
// is always on screen), so the modal close-cancels-editing behavior must not apply there.
|
||||
LaunchedEffect(nav.drawerState.isClosed) {
|
||||
if (nav.drawerState.isClosed) {
|
||||
if (!nav.isDrawerDocked && nav.drawerState.isClosed) {
|
||||
focusManager.clearFocus(true)
|
||||
onDone()
|
||||
} else {
|
||||
@@ -427,6 +466,10 @@ fun StatusEditBar(
|
||||
}
|
||||
|
||||
focusManager.clearFocus(true)
|
||||
// Collapse back to the read-only bar: in the docked drawer no
|
||||
// drawer-close will ever do it, and in the modal drawer this beats
|
||||
// staying in edit mode until the drawer closes.
|
||||
onDone()
|
||||
},
|
||||
),
|
||||
singleLine = true,
|
||||
@@ -442,12 +485,14 @@ fun StatusEditBar(
|
||||
accountViewModel.updateStatus(address, currentStatus.value)
|
||||
}
|
||||
focusManager.clearFocus(true)
|
||||
onDone()
|
||||
}
|
||||
} else {
|
||||
if (address != null) {
|
||||
UserStatusDeleteButton {
|
||||
accountViewModel.deleteStatus(address)
|
||||
focusManager.clearFocus(true)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-17
@@ -18,24 +18,27 @@
|
||||
* 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.note.elements
|
||||
package com.vitorpamplona.amethyst.ui.navigation.navs
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe
|
||||
|
||||
/**
|
||||
* [zonedDrawerSwipe] gated on the drawer actually being modal. With the drawer permanently
|
||||
* docked ([INav.isDrawerDocked]) there is nothing to open, so the left-edge swipe zone is
|
||||
* not attached at all and the pager keeps its own gestures. Use this instead of calling
|
||||
* [zonedDrawerSwipe] directly from screens — the guard then can't be forgotten at new
|
||||
* call sites.
|
||||
*/
|
||||
@Composable
|
||||
fun BoostedMark() {
|
||||
Text(
|
||||
stringRes(id = R.string.boosted),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
modifier = HalfStartPadding,
|
||||
)
|
||||
}
|
||||
fun Modifier.zonedDrawerSwipeIfModal(
|
||||
pagerState: PagerState,
|
||||
nav: INav,
|
||||
): Modifier =
|
||||
if (nav.isDrawerDocked) {
|
||||
this
|
||||
} else {
|
||||
zonedDrawerSwipe(pagerState, nav::openDrawer)
|
||||
}
|
||||
@@ -32,6 +32,14 @@ interface INav {
|
||||
val navigationScope: CoroutineScope
|
||||
val drawerState: DrawerState
|
||||
|
||||
/**
|
||||
* True while the shell renders the drawer permanently docked (Expanded windows). In that
|
||||
* mode [drawerState] never transitions — it stays [androidx.compose.material3.DrawerValue.Closed]
|
||||
* while the drawer is visibly on screen — so drawer consumers (edge swipes, open buttons,
|
||||
* close-driven effects) should consult this instead of inferring from [drawerState].
|
||||
*/
|
||||
val isDrawerDocked: Boolean get() = false
|
||||
|
||||
fun closeDrawer()
|
||||
|
||||
fun openDrawer()
|
||||
|
||||
@@ -26,6 +26,8 @@ import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
@@ -44,11 +46,17 @@ class Nav(
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
/** Set by the shell when the layout tier docks the drawer permanently. */
|
||||
override var isDrawerDocked: Boolean by mutableStateOf(false)
|
||||
|
||||
override fun closeDrawer() {
|
||||
navigationScope.launch { drawerState.close() }
|
||||
}
|
||||
|
||||
override fun openDrawer() {
|
||||
// Nothing renders the modal drawer while it is docked; opening the state would
|
||||
// only strand an Open value for the next modal tier to trip over.
|
||||
if (isDrawerDocked) return
|
||||
navigationScope.launch { drawerState.open() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
+22
-13
@@ -35,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
@@ -62,20 +63,8 @@ fun UserDrawerSearchTopBar(
|
||||
content()
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
// When this screen sits on top of a back stack (entered via the drawer
|
||||
// or any deep link), show a back arrow. When it's the root (entered via
|
||||
// the bottom nav, which clears the stack), show the drawer opener.
|
||||
if (nav.canPop()) {
|
||||
IconButton(onClick = nav::popBack) {
|
||||
ArrowBackIcon()
|
||||
}
|
||||
} else {
|
||||
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
|
||||
}
|
||||
},
|
||||
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
|
||||
actions = {
|
||||
MemoryUsageChip()
|
||||
IconButton(onClick = { nav.nav(Route.Search) }) {
|
||||
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
@@ -83,6 +72,26 @@ fun UserDrawerSearchTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard leading slot for root-capable top bars: a back arrow when the screen sits on
|
||||
* top of a back stack; otherwise the avatar drawer-opener — unless a large-screen shell
|
||||
* already presents the drawer (rail avatar or permanently docked pane), in which case
|
||||
* nothing is shown. Use this instead of hand-writing the branches per top bar.
|
||||
*/
|
||||
@Composable
|
||||
fun TopBarNavigationIcon(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (nav.canPop()) {
|
||||
IconButton(onClick = nav::popBack) {
|
||||
ArrowBackIcon()
|
||||
}
|
||||
} else if (!LocalScreenLayout.current.isLargeScreen) {
|
||||
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LoggedInUserPictureDrawer(
|
||||
accountViewModel: AccountViewModel,
|
||||
|
||||
+2
-2
@@ -472,7 +472,7 @@ fun RenderNIP05VerifiedSymbol(
|
||||
symbol = MaterialSymbols.Downloading,
|
||||
contentDescription = stringRes(id = R.string.nip05_checking),
|
||||
modifier = modifier,
|
||||
tint = Color.Yellow,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -492,7 +492,7 @@ fun RenderNIP05VerifiedSymbol(
|
||||
symbol = MaterialSymbols.Report,
|
||||
contentDescription = stringRes(id = R.string.nip05_failed),
|
||||
modifier = modifier,
|
||||
tint = Color.Red,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -32,7 +33,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -53,7 +53,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -61,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
|
||||
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -77,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost
|
||||
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList
|
||||
import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList
|
||||
@@ -205,10 +206,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDi
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
@@ -216,7 +214,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier
|
||||
@@ -353,9 +351,12 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun NoteCompose(
|
||||
@@ -761,7 +762,6 @@ fun InnerNoteWithReactions(
|
||||
if (showSecondRow) {
|
||||
SecondUserInfoRow(
|
||||
baseNote,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
@@ -883,7 +883,6 @@ fun NoteBody(
|
||||
if (showSecondRow) {
|
||||
SecondUserInfoRow(
|
||||
baseNote,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
@@ -1737,7 +1736,6 @@ fun ReplyNoteComposition(
|
||||
@Composable
|
||||
fun SecondUserInfoRow(
|
||||
note: Note,
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -1746,6 +1744,7 @@ fun SecondUserInfoRow(
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = UserNameMaxRowHeight,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -1756,47 +1755,39 @@ fun SecondUserInfoRow(
|
||||
}
|
||||
}
|
||||
|
||||
val geo = remember(noteEvent) { noteEvent.geoHashOrScope() }
|
||||
if (geo != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayLocation(geo, accountViewModel, nav)
|
||||
}
|
||||
|
||||
val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } }
|
||||
if (baseReward != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayReward(baseReward, note, accountViewModel, nav)
|
||||
DisplayReward(baseReward, note, accountViewModel)
|
||||
}
|
||||
|
||||
val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() }
|
||||
if (pow != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayPoW(pow)
|
||||
}
|
||||
|
||||
DisplayOtsIfInOriginal(note, editState, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayExpiration(expirationDate: Long) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Timer,
|
||||
contentDescription = stringRes(R.string.expiration_date_label),
|
||||
modifier = Modifier.padding(start = 5.dp).size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
val context = LocalContext.current
|
||||
Text(
|
||||
text = timeAheadNoDot(expirationDate, context),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
fun DisplayExpiration(
|
||||
expirationDate: Long,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Beyond a year timeAheadNoDot switches to a full date, which is too wide
|
||||
// for the pill: clamp it.
|
||||
val text =
|
||||
if (expirationDate - TimeUtils.now() > TimeUtils.ONE_YEAR) {
|
||||
stringRes(R.string.one_year_plus)
|
||||
} else {
|
||||
timeAheadNoDot(expirationDate, context)
|
||||
}
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.Timer,
|
||||
text = text,
|
||||
contentDescription = stringRes(R.string.expiration_date_label),
|
||||
onClick = {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.expiration_date_label,
|
||||
R.string.expiration_info_description,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date(expirationDate * 1000)),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1816,25 +1807,12 @@ fun DisplayOtsIfInOriginal(
|
||||
|
||||
@Composable
|
||||
fun DisplayDraft() {
|
||||
Text(
|
||||
"Draft",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
modifier = HalfStartPadding,
|
||||
)
|
||||
QuietMark(text = stringRes(R.string.draft))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayDraftChat() {
|
||||
Text(
|
||||
"Draft",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = Font12SP,
|
||||
maxLines = 1,
|
||||
)
|
||||
QuietMark(text = stringRes(R.string.draft))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1847,7 +1825,11 @@ fun FirstUserInfoRow(
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
modifier = UserNameRowHeight,
|
||||
) {
|
||||
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
|
||||
val isDraft = baseNote.isDraft()
|
||||
val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified
|
||||
@@ -1864,17 +1846,21 @@ fun FirstUserInfoRow(
|
||||
if (zapSender != null) {
|
||||
if (showAuthorPicture) {
|
||||
UserPicture(zapSender, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(HalfPadding)
|
||||
}
|
||||
UsernameDisplay(zapSender, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
|
||||
} else if (showAuthorPicture) {
|
||||
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(HalfPadding)
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
CheckAndDisplayEditStatus(editState)
|
||||
|
||||
if (isDraft) {
|
||||
DisplayDraft()
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
|
||||
val isCommunityPost =
|
||||
@@ -1901,55 +1887,65 @@ fun FirstUserInfoRow(
|
||||
}
|
||||
}
|
||||
|
||||
if (isRepost) {
|
||||
BoostedMark()
|
||||
val noteEvent = baseNote.event
|
||||
|
||||
val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() }
|
||||
if (geo != null) {
|
||||
DisplayLocation(geo, accountViewModel, nav)
|
||||
}
|
||||
|
||||
CheckAndDisplayEditStatus(editState)
|
||||
DisplayOtsIfInOriginal(baseNote, editState, accountViewModel)
|
||||
|
||||
if (isDraft) {
|
||||
DisplayDraft()
|
||||
val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() }
|
||||
if (pow != null) {
|
||||
DisplayPoW(pow, accountViewModel)
|
||||
}
|
||||
|
||||
Expiration(baseNote, accountViewModel)
|
||||
|
||||
if (baseNote.isPrivateRumor()) {
|
||||
PrivateRumorMark()
|
||||
PrivateRumorMark(accountViewModel)
|
||||
}
|
||||
|
||||
if (isPinned) {
|
||||
PinnedMark()
|
||||
}
|
||||
|
||||
Expiration(baseNote)
|
||||
|
||||
JumpToParentReplyButton(baseNote, accountViewModel, nav)
|
||||
|
||||
TimeAgo(baseNote)
|
||||
// The dotted TimeAgo carries its own " • " separator and the options
|
||||
// button has slack around its icon, so this pair stays unspaced.
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
TimeAgo(baseNote, style = TimeAgoStyle.DottedTight)
|
||||
|
||||
if (moreOptions == null) {
|
||||
MoreOptionsButton(baseNote, editState, accountViewModel, nav)
|
||||
} else {
|
||||
moreOptions()
|
||||
if (moreOptions == null) {
|
||||
MoreOptionsButton(baseNote, editState, accountViewModel, nav)
|
||||
} else {
|
||||
moreOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PinnedMark() {
|
||||
Icon(
|
||||
QuietMark(
|
||||
symbol = MaterialSymbols.PushPin,
|
||||
contentDescription = stringRes(R.string.pinned_notes),
|
||||
modifier = Modifier.padding(start = 5.dp).size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrivateRumorMark() {
|
||||
Icon(
|
||||
fun PrivateRumorMark(accountViewModel: AccountViewModel) {
|
||||
QuietMark(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = stringRes(R.string.private_rumor_mark),
|
||||
modifier = Modifier.padding(start = 5.dp).size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
onClick = {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.private_rumor_info_title,
|
||||
R.string.private_rumor_info_description,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1975,7 +1971,7 @@ fun JumpToParentReplyButton(
|
||||
} ?: return
|
||||
|
||||
ClickableBox(
|
||||
modifier = Modifier.padding(start = 5.dp).size(20.dp),
|
||||
modifier = Modifier.size(20.dp),
|
||||
onClick = {
|
||||
nav.nav { routeFor(parentNote, accountViewModel.account) }
|
||||
},
|
||||
@@ -1990,12 +1986,15 @@ fun JumpToParentReplyButton(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Expiration(note: Note) {
|
||||
fun Expiration(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event != null) {
|
||||
val expires = remember(event) { event.expiration() }
|
||||
if (expires != null) {
|
||||
DisplayExpiration(expires)
|
||||
DisplayExpiration(expires, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
@@ -45,7 +45,7 @@ fun DisplayFollowingCommunityInPost(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(HalfStartPadding) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ private fun DisplayCommunity(
|
||||
|
||||
ClickableTextColor(
|
||||
label,
|
||||
linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f),
|
||||
linkColor = MaterialTheme.colorScheme.lessImportantLink,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
) {
|
||||
|
||||
+21
-26
@@ -20,38 +20,33 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
/**
|
||||
* Pencil marking an edited note; tapping cycles through the versions. The
|
||||
* bare pencil is the latest edit — a suffix appears only while browsing
|
||||
* older versions.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayEditStatus(editState: EditState) {
|
||||
Text(
|
||||
text =
|
||||
buildAnnotatedString {
|
||||
if (editState.showingVersion.value == editState.originalVersionId()) {
|
||||
append(stringRes(id = R.string.original))
|
||||
} else if (editState.showingVersion.value == editState.lastVersionId()) {
|
||||
append(stringRes(id = R.string.edited))
|
||||
} else {
|
||||
append(stringRes(id = R.string.edited_number, editState.versionId()))
|
||||
}
|
||||
},
|
||||
style =
|
||||
LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
maxLines = 1,
|
||||
modifier = HalfStartPadding.clickable { editState.nextModification() },
|
||||
val label =
|
||||
if (editState.showingVersion.value == editState.lastVersionId()) {
|
||||
null
|
||||
} else if (editState.showingVersion.value == editState.originalVersionId()) {
|
||||
stringRes(id = R.string.original)
|
||||
} else {
|
||||
"#${editState.versionId()}"
|
||||
}
|
||||
|
||||
QuietMark(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
text = label,
|
||||
contentDescription = stringRes(id = R.string.edited),
|
||||
onClick = { editState.nextModification() },
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
@@ -82,7 +83,7 @@ private fun DisplayTagList(
|
||||
) {
|
||||
ClickableTextColor(
|
||||
"#$firstTag",
|
||||
linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f),
|
||||
linkColor = MaterialTheme.colorScheme.lessImportantLink,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
) {
|
||||
|
||||
+16
-27
@@ -20,20 +20,23 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
|
||||
/**
|
||||
* Compact pill showing the geohash a note is scoped to, resolved to a city
|
||||
* name. Tapping it opens the geohash feed. Sits inline in note headers.
|
||||
* City names are unbounded user data, so the pill is capped and ellipsizes
|
||||
* to protect the author's name from being squeezed out of the row.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayLocation(
|
||||
geohashStr: String,
|
||||
@@ -41,25 +44,11 @@ fun DisplayLocation(
|
||||
nav: INav,
|
||||
) {
|
||||
LoadCityName(geohashStr) { cityName ->
|
||||
Text(
|
||||
text =
|
||||
buildAnnotatedString {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(geohashStr)) },
|
||||
) {
|
||||
append(cityName)
|
||||
}
|
||||
},
|
||||
style =
|
||||
LocalTextStyle.current.copy(
|
||||
color =
|
||||
MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.52f,
|
||||
),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
maxLines = 1,
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.LocationOn,
|
||||
text = cityName,
|
||||
modifier = Modifier.widthIn(max = 110.dp),
|
||||
onClick = { nav.nav(Route.Geohash(geohashStr)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,27 +20,27 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.buildLinkString
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadOts
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Compact pill showing the note's NIP-03 OpenTimestamps proof: how long ago
|
||||
* the note is proven to have existed. Tapping it explains the proof and shows
|
||||
* the attested date. Sits inline in note headers.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayOts(
|
||||
note: Note,
|
||||
@@ -60,31 +60,33 @@ fun DisplayOts(
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text =
|
||||
buildLinkString(stringRes(R.string.existed_since, timeStr)) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.ots_info_title,
|
||||
R.string.ots_info_description,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)),
|
||||
)
|
||||
},
|
||||
style =
|
||||
LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.lessImportantLink,
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
maxLines = 1,
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.OpenTimestamps,
|
||||
text = timeStr,
|
||||
contentDescription = stringRes(R.string.ots_info_title),
|
||||
onClick = {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.ots_info_title,
|
||||
R.string.ots_info_description,
|
||||
SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
whenPending = {
|
||||
Text(
|
||||
stringRes(id = R.string.timestamp_pending_short),
|
||||
color = MaterialTheme.colorScheme.lessImportantLink,
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
// The stamp icon plus an ellipsis: attestation requested, not yet
|
||||
// anchored. The content description carries the words; tapping
|
||||
// explains what is being waited on.
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.OpenTimestamps,
|
||||
text = "…",
|
||||
contentDescription = stringRes(R.string.timestamp_pending_short),
|
||||
onClick = {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.ots_info_title,
|
||||
R.string.ots_info_pending_description,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,25 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
|
||||
@@ -46,39 +34,30 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
@Preview
|
||||
fun DisplayPoWPreview() {
|
||||
ThemeComparisonColumn(
|
||||
toPreview = { DisplayPoW(pow = 24) },
|
||||
toPreview = { DisplayPoW(pow = 24, accountViewModel = mockAccountViewModel()) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill showing the proof of work a received note carries: a bolt plus
|
||||
* the difficulty in leading zero bits. Sits inline in note headers, so it
|
||||
* stays at text height.
|
||||
* Compact pill showing the proof of work a received note carries: a gear plus
|
||||
* the difficulty in leading zero bits. Tapping it explains what the number
|
||||
* means. Sits inline in note headers, so it stays at text height.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayPoW(pow: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Manufacturing,
|
||||
contentDescription = stringRes(R.string.pow_settings_title),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(10.dp),
|
||||
)
|
||||
Text(
|
||||
text = pow.toString(),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
fun DisplayPoW(
|
||||
pow: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.Manufacturing,
|
||||
text = pow.toString(),
|
||||
contentDescription = stringRes(R.string.pow_settings_title),
|
||||
onClick = {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.pow_settings_title,
|
||||
R.string.pow_info_description,
|
||||
pow.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+12
-34
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
@@ -55,20 +54,17 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.note.buttons.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -83,28 +79,13 @@ fun DisplayReward(
|
||||
baseReward: Reward,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { popupExpanded = true },
|
||||
) {
|
||||
ClickableTextColor(
|
||||
"#bounty",
|
||||
linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f),
|
||||
) {
|
||||
nav.nav(Route.Hashtag("bounty"))
|
||||
}
|
||||
RenderPledgeAmount(baseNote, baseReward, accountViewModel) { popupExpanded = true }
|
||||
|
||||
RenderPledgeAmount(baseNote, baseReward, accountViewModel)
|
||||
}
|
||||
|
||||
if (popupExpanded) {
|
||||
AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false }
|
||||
}
|
||||
if (popupExpanded) {
|
||||
AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +94,7 @@ private fun RenderPledgeAmount(
|
||||
baseNote: Note,
|
||||
baseReward: Reward,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val repliesState by observeNoteReplies(baseNote, accountViewModel)
|
||||
var reward by remember {
|
||||
@@ -142,16 +124,12 @@ private fun RenderPledgeAmount(
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPledge) {
|
||||
ZappedIcon(modifier = Size20Modifier)
|
||||
} else {
|
||||
ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
Text(
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
text = reward,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
contentDescription = stringRes(R.string.bounty_label),
|
||||
iconTint = if (hasPledge) BitcoinOrange else null,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,25 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
|
||||
|
||||
@Composable
|
||||
@@ -53,20 +49,21 @@ fun ShowForkInformation(
|
||||
if (forkedAddress != null) {
|
||||
LoadAddressableNote(forkedAddress, accountViewModel) { addressableNote ->
|
||||
if (addressableNote != null) {
|
||||
ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav)
|
||||
ForkMark(addressableNote, modifier, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
} else if (forkedEvent != null) {
|
||||
LoadNote(forkedEvent, accountViewModel) { event ->
|
||||
if (event != null) {
|
||||
ForkInformationRowLightColor(event, modifier, accountViewModel, nav)
|
||||
ForkMark(event, modifier, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fork-right icon marking a forked note; tapping opens the original version. */
|
||||
@Composable
|
||||
fun ForkInformationRowLightColor(
|
||||
fun ForkMark(
|
||||
originalVersion: Note,
|
||||
modifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -74,22 +71,14 @@ fun ForkInformationRowLightColor(
|
||||
) {
|
||||
val noteState by observeNote(originalVersion, accountViewModel)
|
||||
val note = noteState.note
|
||||
val author = note.author ?: return
|
||||
val route = remember(note) { routeFor(note, accountViewModel.account) }
|
||||
|
||||
if (route != null) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
val userState by observeUserInfo(author, accountViewModel)
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = stringRes(id = R.string.forked_from) + " " + (userState?.info?.bestName() ?: author.pubkeyDisplayHex()),
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
overrideColor = MaterialTheme.colorScheme.primary,
|
||||
fontSize = Font14SP,
|
||||
nav = nav,
|
||||
tags = userState?.tags,
|
||||
)
|
||||
}
|
||||
QuietMark(
|
||||
symbol = MaterialSymbols.ForkRight,
|
||||
contentDescription = stringRes(id = R.string.forked_from),
|
||||
modifier = modifier,
|
||||
onClick = { nav.nav(route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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.note.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.FirstUserInfoRow
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EmptyState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val AUTHOR = "a".repeat(64)
|
||||
private val LONG_NAME_AUTHOR = "b".repeat(64)
|
||||
|
||||
private val AUTHOR_METADATA_ID = "e1".repeat(32)
|
||||
private val LONG_NAME_METADATA_ID = "e2".repeat(32)
|
||||
|
||||
private val PLAIN_ID = "1".repeat(64)
|
||||
private val RUMOR_ID = "3".repeat(64)
|
||||
private val EDITED_ID = "4".repeat(64)
|
||||
private val EDIT_VERSION_ID = "5".repeat(64)
|
||||
private val DRAFT_ID = "6".repeat(64)
|
||||
private val COMMUNITY_POST_ID = "7".repeat(64)
|
||||
|
||||
// 24 leading zero bits so strongPoWOrNull(min = 20) accepts the committed nonce.
|
||||
private val POW_ID = "0".repeat(6) + "8".repeat(58)
|
||||
private val KITCHEN_SINK_ID = "0".repeat(6) + "9".repeat(58)
|
||||
|
||||
private const val GEOHASH = "u1x1"
|
||||
private val COMMUNITY_ADDRESS = "34550:" + "c".repeat(64) + ":amethyst-users"
|
||||
|
||||
/**
|
||||
* Design-system preview for the note-header first row, rendered by the REAL
|
||||
* `FirstUserInfoRow` over notes seeded into [LocalCache] — the same pattern
|
||||
* the ZapPollNote/Poll previews use. Rows go from bare to fully loaded so the
|
||||
* two marker tiers (`HeaderPill` and `QuietMark`, now in `commons/ui/note`)
|
||||
* can be reviewed together.
|
||||
*
|
||||
* Two markers cannot appear in a static preview because their visibility is
|
||||
* computed inside effects that previews never run: the followed-hashtag label
|
||||
* (needs the account follow-list flows) and the *confirmed* OTS pill (needs
|
||||
* attestation verification). The community label and the *pending* OTS pill
|
||||
* shown here exercise the same two visual styles through real code.
|
||||
*/
|
||||
@Preview(widthDp = 400)
|
||||
@Composable
|
||||
fun NoteHeaderFirstRowDensityPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav = EmptyNav()
|
||||
|
||||
// The jump-to-parent arrow only renders in complete UI mode.
|
||||
accountViewModel.settings.uiSettingsFlow.featureSet.value = FeatureSetType.COMPLETE
|
||||
|
||||
// Let DisplayLocation resolve the geohash synchronously from the cache.
|
||||
CachedReversedGeoLocations.locationNames.put(GEOHASH, "Belo Horizonte")
|
||||
|
||||
val now = TimeUtils.now()
|
||||
val expiresAt = (now + 7200).toString()
|
||||
|
||||
val plainEvent = TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x")
|
||||
|
||||
// Drafts are addressable: consumption lands on the AddressableNote, so the
|
||||
// preview must fetch it by address rather than by event id.
|
||||
val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x")
|
||||
|
||||
val plain: Note = LocalCache.getOrCreateNote(PLAIN_ID)
|
||||
val rumorPinned: Note = LocalCache.getOrCreateNote(RUMOR_ID)
|
||||
val edited: Note = LocalCache.getOrCreateNote(EDITED_ID)
|
||||
val editVersion: Note = LocalCache.getOrCreateNote(EDIT_VERSION_ID)
|
||||
val draft: Note = LocalCache.getOrCreateAddressableNote(draftEvent.address())
|
||||
val communityPost: Note = LocalCache.getOrCreateNote(COMMUNITY_POST_ID)
|
||||
val geoPowExpiring: Note = LocalCache.getOrCreateNote(POW_ID)
|
||||
val kitchenSink: Note = LocalCache.getOrCreateNote(KITCHEN_SINK_ID)
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(
|
||||
MetadataEvent(AUTHOR_METADATA_ID, AUTHOR, now, emptyArray(), """{"name":"Vitor"}""", "x"),
|
||||
null,
|
||||
true,
|
||||
)
|
||||
LocalCache.justConsume(
|
||||
MetadataEvent(LONG_NAME_METADATA_ID, LONG_NAME_AUTHOR, now, emptyArray(), """{"name":"A user with a very long display name"}""", "x"),
|
||||
null,
|
||||
true,
|
||||
)
|
||||
|
||||
LocalCache.justConsume(plainEvent, null, true)
|
||||
LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, true)
|
||||
// An empty sig is what marks a note as a private rumor.
|
||||
LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, true)
|
||||
LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, true)
|
||||
LocalCache.justConsume(draftEvent, null, true)
|
||||
LocalCache.justConsume(
|
||||
TextNoteEvent(COMMUNITY_POST_ID, AUTHOR, now - 300, arrayOf(arrayOf("a", COMMUNITY_ADDRESS)), "hello community", "x"),
|
||||
null,
|
||||
true,
|
||||
)
|
||||
LocalCache.justConsume(
|
||||
TextNoteEvent(
|
||||
POW_ID,
|
||||
AUTHOR,
|
||||
now - 300,
|
||||
arrayOf(
|
||||
arrayOf("g", GEOHASH),
|
||||
arrayOf("nonce", "776", "24"),
|
||||
arrayOf("expiration", expiresAt),
|
||||
),
|
||||
"mined and placed",
|
||||
"x",
|
||||
),
|
||||
null,
|
||||
true,
|
||||
)
|
||||
LocalCache.justConsume(
|
||||
TextNoteEvent(
|
||||
KITCHEN_SINK_ID,
|
||||
LONG_NAME_AUTHOR,
|
||||
now - 300,
|
||||
arrayOf(
|
||||
arrayOf("e", PLAIN_ID),
|
||||
arrayOf("a", COMMUNITY_ADDRESS),
|
||||
arrayOf("g", GEOHASH),
|
||||
arrayOf("nonce", "776", "24"),
|
||||
arrayOf("expiration", expiresAt),
|
||||
),
|
||||
"everything everywhere all at once",
|
||||
// Empty sig: also a private rumor.
|
||||
"",
|
||||
),
|
||||
null,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadOts renders the pending pill synchronously from this map; the
|
||||
// confirmed pill needs async verification a static preview can't run.
|
||||
accountViewModel.account.settings.pendingAttestations
|
||||
.update { it + (POW_ID to "stamp") + (KITCHEN_SINK_ID to "stamp") }
|
||||
|
||||
val editedState =
|
||||
remember {
|
||||
mutableStateOf<GenericLoadable<EditState>>(
|
||||
GenericLoadable.Loaded(EditState().apply { updateModifications(listOf(editVersion)) }),
|
||||
)
|
||||
}
|
||||
|
||||
ThemeComparisonColumn {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
// Bare minimum: username + time + options
|
||||
HeaderRowSample(plain, accountViewModel)
|
||||
|
||||
// Quiet icon marks: private rumor + pinned
|
||||
HeaderRowSample(rumorPinned, accountViewModel, isPinned = true)
|
||||
|
||||
// Quiet text mark, tappable: edited
|
||||
HeaderRowSample(edited, accountViewModel, editState = editedState)
|
||||
|
||||
// Quiet text mark: draft
|
||||
HeaderRowSample(draft, accountViewModel)
|
||||
|
||||
// Soft link: community label
|
||||
HeaderRowSample(communityPost, accountViewModel)
|
||||
|
||||
// Pill cluster: location + PoW + pending OTS + expiration
|
||||
HeaderRowSample(geoPowExpiring, accountViewModel)
|
||||
|
||||
// Kitchen sink under a long username: everything competes for space
|
||||
HeaderRowSample(kitchenSink, accountViewModel, isPinned = true, editState = editedState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderRowSample(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
isPinned: Boolean = false,
|
||||
editState: State<GenericLoadable<EditState>> = EmptyState,
|
||||
) {
|
||||
FirstUserInfoRow(
|
||||
baseNote = note,
|
||||
showAuthorPicture = false,
|
||||
isPinned = isPinned,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,12 @@ enum class TimeAgoStyle {
|
||||
/** "• 5m", uses [timeAgo]. Used by note timestamps and chatroom row last-message time. */
|
||||
Dotted,
|
||||
|
||||
/**
|
||||
* "• 5m" without the leading space — for rows whose `Arrangement.spacedBy`
|
||||
* already provides the gap before the timestamp (the note-header first row).
|
||||
*/
|
||||
DottedTight,
|
||||
|
||||
/** "5m" (no dot, no leading space), uses [timeAgoShort]. Used by chat bubbles and channel headers. */
|
||||
Short,
|
||||
}
|
||||
@@ -96,6 +102,7 @@ fun ToggleableTimeAgoText(
|
||||
if (showAbsolute) {
|
||||
when (style) {
|
||||
TimeAgoStyle.Dotted -> timeAbsolute(timestamp, context)
|
||||
TimeAgoStyle.DottedTight -> timeAbsolute(timestamp, context).trimStart()
|
||||
TimeAgoStyle.Short -> timeAbsoluteNoDot(timestamp, context)
|
||||
}
|
||||
} else {
|
||||
@@ -104,6 +111,7 @@ fun ToggleableTimeAgoText(
|
||||
nowState.value
|
||||
when (style) {
|
||||
TimeAgoStyle.Dotted -> timeAgo(timestamp, context)
|
||||
TimeAgoStyle.DottedTight -> timeAgo(timestamp, context).trimStart()
|
||||
TimeAgoStyle.Short -> timeAgoShort(timestamp, nowStr)
|
||||
}
|
||||
}
|
||||
@@ -125,16 +133,22 @@ fun ToggleableTimeAgoText(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimeAgo(note: Note) {
|
||||
fun TimeAgo(
|
||||
note: Note,
|
||||
style: TimeAgoStyle = TimeAgoStyle.Dotted,
|
||||
) {
|
||||
val time = note.createdAt() ?: return
|
||||
TimeAgo(time)
|
||||
TimeAgo(time, style)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimeAgo(time: Long) {
|
||||
fun TimeAgo(
|
||||
time: Long,
|
||||
style: TimeAgoStyle = TimeAgoStyle.Dotted,
|
||||
) {
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = time,
|
||||
style = TimeAgoStyle.Dotted,
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
+140
-31
@@ -22,29 +22,43 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.PermanentDrawerContent
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSelectionDrag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSidePanel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -75,40 +89,31 @@ fun AccountSwitcherAndLeftDrawerLayout(
|
||||
}
|
||||
}
|
||||
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
val currentDrawerState = nav.drawerState.currentValue
|
||||
LaunchedEffect(key1 = orientation) {
|
||||
if (
|
||||
orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed
|
||||
) {
|
||||
nav.drawerState.close()
|
||||
// The layout tier can change while the app runs (fold/unfold, rotation, window resize)
|
||||
// and the shells below place `content` at different composition positions. Movable
|
||||
// content lets the whole NavHost subtree MOVE between those positions instead of being
|
||||
// disposed and rebuilt, preserving every screen's remember/rememberSaveable state.
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
val movableContent = remember { movableContentOf { currentContent() } }
|
||||
|
||||
val docked = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER
|
||||
|
||||
// Publish docked-ness on the Nav so drawer consumers (openDrawer, edge swipes, the
|
||||
// status editor) can behave correctly without each re-deriving the layout tier.
|
||||
LaunchedEffect(docked) {
|
||||
nav.isDrawerDocked = docked
|
||||
// Entering the permanent tier with the modal drawer still Open would otherwise
|
||||
// carry the stale Open value back to the modal tier and pop the drawer uninvited.
|
||||
if (docked && !nav.drawerState.isClosed) {
|
||||
nav.drawerState.snapTo(DrawerValue.Closed)
|
||||
}
|
||||
}
|
||||
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
val isTabPagerRoute =
|
||||
navBackStackEntry?.destination?.let { dest ->
|
||||
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
|
||||
} ?: false
|
||||
val drawerGesturesEnabled =
|
||||
(
|
||||
!isTabPagerRoute ||
|
||||
nav.drawerState.isOpen ||
|
||||
nav.drawerState.targetValue != nav.drawerState.currentValue
|
||||
) &&
|
||||
// Suspend the left-edge swipe while a selection/caret handle is dragged over an embedded surface,
|
||||
// so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer.
|
||||
!EmbeddedSelectionDrag.dragging
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = nav.drawerState,
|
||||
gesturesEnabled = drawerGesturesEnabled,
|
||||
drawerContent = {
|
||||
DrawerContent(nav, openSheetFunction, accountViewModel)
|
||||
BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
if (docked) {
|
||||
PermanentDrawerShell(accountViewModel, nav, openSheetFunction, movableContent)
|
||||
} else {
|
||||
ModalDrawerShell(accountViewModel, nav, openSheetFunction, movableContent)
|
||||
}
|
||||
|
||||
// Sheet content
|
||||
if (openAccountSwitcherBottomSheet) {
|
||||
@@ -131,3 +136,107 @@ fun AccountSwitcherAndLeftDrawerLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact and Medium windows: the drawer slides in as a modal sheet. On Medium an
|
||||
* [AppNavigationRail] sits at the left edge in place of the phone bottom bar.
|
||||
*/
|
||||
@Composable
|
||||
private fun ModalDrawerShell(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
openSheet: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
LaunchedEffect(key1 = orientation) {
|
||||
// Dismiss an open drawer when the device rotates to landscape; the layout
|
||||
// underneath changes too much for the sheet to stay meaningful.
|
||||
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
nav.drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
val isTabPagerRoute =
|
||||
navBackStackEntry?.destination?.let { dest ->
|
||||
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
|
||||
} ?: false
|
||||
val drawerGesturesEnabled =
|
||||
(
|
||||
!isTabPagerRoute ||
|
||||
nav.drawerState.isOpen ||
|
||||
nav.drawerState.targetValue != nav.drawerState.currentValue
|
||||
) &&
|
||||
// Suspend the left-edge swipe while a selection/caret handle is dragged over an embedded surface,
|
||||
// so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer.
|
||||
!EmbeddedSelectionDrag.dragging
|
||||
|
||||
val showRail = LocalScreenLayout.current.navigationStyle == NavigationStyle.NAV_RAIL
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = nav.drawerState,
|
||||
gesturesEnabled = drawerGesturesEnabled,
|
||||
drawerContent = {
|
||||
DrawerContent(nav, openSheet, accountViewModel)
|
||||
BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer)
|
||||
},
|
||||
content = {
|
||||
if (showRail) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
AppNavigationRail(nav, accountViewModel)
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
CenterPane(Modifier.weight(1f), content)
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded windows: the drawer is permanently docked on the left, the bottom bar disappears,
|
||||
* and — when the window is wide enough — the notification feed docks on the right.
|
||||
*/
|
||||
@Composable
|
||||
private fun PermanentDrawerShell(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: Nav,
|
||||
openSheet: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
// The panel duplicates the Notifications screen, so it steps aside while the user is there.
|
||||
val showPanel =
|
||||
LocalScreenLayout.current.showsNotificationPanel &&
|
||||
navBackStackEntry?.destination?.hasRoute<Route.Notification>() != true
|
||||
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
PermanentDrawerContent(nav, openSheet, accountViewModel)
|
||||
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
|
||||
CenterPane(Modifier.weight(1f), content)
|
||||
|
||||
if (showPanel) {
|
||||
VerticalDivider(thickness = DividerThickness)
|
||||
NotificationSidePanel(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the navigation content. Screen width capping happens per NavHost destination
|
||||
* ([com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent] via the NavigationEffects
|
||||
* builders), so this pane just claims the leftover row width.
|
||||
*/
|
||||
@Composable
|
||||
private fun CenterPane(
|
||||
modifier: Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(modifier.fillMaxHeight()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-15
@@ -47,12 +47,12 @@ 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.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload
|
||||
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
|
||||
@@ -178,20 +178,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
-3
@@ -28,12 +28,16 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
@@ -313,8 +317,9 @@ private fun OmniBar(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// The omnibox is a plain Row in the topBar slot (not a Material3 TopAppBar), so it must
|
||||
// apply the status-bar inset itself — otherwise it draws under the status bar.
|
||||
.statusBarsPadding()
|
||||
// apply the top system insets itself — systemBars rather than just statusBars, so the
|
||||
// caption bar of a desktop window (Waydroid/DeX freeform) is respected too.
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top))
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
||||
+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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ fun NormalChatNote(
|
||||
val pow = remember(note) { note.event?.strongPoWOrNull() }
|
||||
if (pow != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayPoW(pow)
|
||||
DisplayPoW(pow, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
DisplayDraftChat()
|
||||
|
||||
+6
-31
@@ -20,20 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -59,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -571,31 +566,11 @@ private fun RelayNameChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(11.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
HeaderPill(
|
||||
symbol = MaterialSymbols.Dns,
|
||||
text = label,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+30
-36
@@ -20,15 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane
|
||||
@@ -40,37 +39,32 @@ fun MessagesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val act = LocalContext.current.getActivity()
|
||||
val windowSizeClass = calculateWindowSizeClass(act)
|
||||
// Decide single- vs two-pane from the pane this screen actually occupies, not the
|
||||
// window: on large screens the shell already spends width on the rail / permanent
|
||||
// drawer / notification panel, so window-level size classes would overestimate.
|
||||
// calculateFromSize keeps the Compact/Medium/Expanded breakpoints in one place
|
||||
// (Material's) instead of restating 600/840 here.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val paneWidthClass =
|
||||
WindowSizeClass
|
||||
.calculateFromSize(DpSize(maxWidth, maxHeight))
|
||||
.widthSizeClass
|
||||
|
||||
val twoPane by remember(windowSizeClass.widthSizeClass) {
|
||||
derivedStateOf {
|
||||
when (windowSizeClass.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact -> false
|
||||
|
||||
WindowWidthSizeClass.Expanded,
|
||||
WindowWidthSizeClass.Medium,
|
||||
-> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
if (paneWidthClass == WindowWidthSizeClass.Compact) {
|
||||
MessagesSinglePane(
|
||||
knownFeedContentState = accountViewModel.feedStates.dmKnown,
|
||||
newFeedContentState = accountViewModel.feedStates.dmNew,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
MessagesTwoPane(
|
||||
knownFeedContentState = accountViewModel.feedStates.dmKnown,
|
||||
newFeedContentState = accountViewModel.feedStates.dmNew,
|
||||
widthSizeClass = paneWidthClass,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (twoPane) {
|
||||
MessagesTwoPane(
|
||||
knownFeedContentState = accountViewModel.feedStates.dmKnown,
|
||||
newFeedContentState = accountViewModel.feedStates.dmNew,
|
||||
widthSizeClass = windowSizeClass.widthSizeClass,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
MessagesSinglePane(
|
||||
knownFeedContentState = accountViewModel.feedStates.dmKnown,
|
||||
newFeedContentState = accountViewModel.feedStates.dmNew,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -42,12 +42,12 @@ import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
@@ -124,11 +124,7 @@ fun MessagesPager(
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
modifier =
|
||||
modifier.zonedDrawerSwipe(
|
||||
pagerState = pagerState,
|
||||
openDrawer = nav::openDrawer,
|
||||
),
|
||||
modifier = modifier.zonedDrawerSwipeIfModal(pagerState, nav),
|
||||
) { page ->
|
||||
ChatroomListFeedView(
|
||||
feedContentState = tabs[page].feedContentState,
|
||||
|
||||
+4
-1
@@ -63,8 +63,11 @@ fun MessagesTwoPane(
|
||||
val scope = rememberCoroutineScope()
|
||||
val twoPaneNav = remember { TwoPaneNav(nav, scope) }
|
||||
|
||||
// Keyed on the size class: the pane can cross the Medium/Expanded boundary while this
|
||||
// screen stays composed (window resize, the notification panel docking/undocking), and
|
||||
// an unkeyed remember would keep serving the stale split fraction.
|
||||
val strategy =
|
||||
remember {
|
||||
remember(widthSizeClass) {
|
||||
if (widthSizeClass == WindowWidthSizeClass.Expanded) {
|
||||
HorizontalTwoPaneStrategy(splitFraction = 1f / 3f)
|
||||
} else {
|
||||
|
||||
+2
-6
@@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
@@ -79,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -276,11 +276,7 @@ private fun HomePages(
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
modifier =
|
||||
Modifier.zonedDrawerSwipe(
|
||||
pagerState = pagerState,
|
||||
openDrawer = nav::openDrawer,
|
||||
),
|
||||
modifier = Modifier.zonedDrawerSwipeIfModal(pagerState, nav),
|
||||
) { page ->
|
||||
HomeFeeds(
|
||||
feedState = tabs[page].feedState,
|
||||
|
||||
+2
-9
@@ -39,9 +39,8 @@ import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -70,13 +69,7 @@ fun NappletsTopBar(
|
||||
NappletsTopNavFilterBar(accountViewModel)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (nav.canPop()) {
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
} else {
|
||||
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
|
||||
}
|
||||
},
|
||||
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
|
||||
actions = {
|
||||
IconButton(onClick = { nav.nav(Route.ConnectedApps) }) {
|
||||
Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions))
|
||||
|
||||
+9
-2
@@ -214,16 +214,23 @@ private fun SplitNotificationsScaffold(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refreshable notifications card feed (list + scroll-to-top watcher + inbox-relay
|
||||
* warning header). Shared between the Notifications screen and the docked
|
||||
* [NotificationSidePanel], parameterized by the persisted scroll-state key so each
|
||||
* surface keeps its own position.
|
||||
*/
|
||||
@Composable
|
||||
private fun SingleNotificationsBody(
|
||||
internal fun SingleNotificationsBody(
|
||||
notifFeedContentState: CardFeedContentState,
|
||||
notifPolls: OpenPollsState,
|
||||
scrollToEventId: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
scrollStateKey: String = ScrollStateKeys.NOTIFICATION_SCREEN,
|
||||
) {
|
||||
RefresheableBox(notifFeedContentState, true) {
|
||||
val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN)
|
||||
val listState = rememberForeverLazyListState(scrollStateKey)
|
||||
|
||||
WatchScrollToTop(notifFeedContentState, listState)
|
||||
|
||||
|
||||
+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.ui.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size12dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
|
||||
/**
|
||||
* The docked notification feed shown on very wide windows, to the right of the center pane.
|
||||
* It renders the same card feed body as the Notifications screen (same last-read marking —
|
||||
* cards mark themselves read as they become visible here, exactly as they would on the
|
||||
* screen, so the new-item dot only lights for items the panel hasn't displayed). When the
|
||||
* user has split notifications enabled, the panel shows the Following feed to match the
|
||||
* screen's default tab. Tapping the header opens the full screen, which adds the summary
|
||||
* chart and the Following/Everyone tabs.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationSidePanel(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val split by accountViewModel.account.settings.splitNotificationsEnabled
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val notifFeedContentState =
|
||||
if (split) {
|
||||
accountViewModel.feedStates.notificationsFollowing
|
||||
} else {
|
||||
accountViewModel.feedStates.notifications
|
||||
}
|
||||
val scrollStateKey =
|
||||
if (split) {
|
||||
ScrollStateKeys.NOTIFICATION_SIDE_PANEL_FOLLOWING
|
||||
} else {
|
||||
ScrollStateKeys.NOTIFICATION_SIDE_PANEL
|
||||
}
|
||||
|
||||
WatchAccountForNotifications(notifFeedContentState, accountViewModel)
|
||||
|
||||
// The Surface provides the Material container color + onBackground as LocalContentColor;
|
||||
// in a bare Row the default text color falls back to Color.Black and is invisible on the
|
||||
// dark theme (same reason DisappearingScaffold roots itself in a Surface).
|
||||
Surface(
|
||||
modifier = modifier.width(NotificationPanelWidth).fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
Column(
|
||||
Modifier.windowInsetsPadding(
|
||||
WindowInsets.systemBars.only(
|
||||
WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End,
|
||||
),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(Route.Notification()) }
|
||||
.padding(horizontal = Size16dp, vertical = Size12dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.route_notifications),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
SingleNotificationsBody(
|
||||
notifFeedContentState = notifFeedContentState,
|
||||
notifPolls = accountViewModel.feedStates.notificationsOpenPolls,
|
||||
scrollToEventId = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
scrollStateKey = scrollStateKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
@@ -335,7 +336,10 @@ fun ProfileScreen(
|
||||
topBar = {
|
||||
ProfileTopBar(baseUser, accountViewModel, nav)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
// Status-bar handling is done inside RenderSurface, but the bottom inset must stay:
|
||||
// when AppBottomBar renders nothing (pushed entries on phones, all large-screen
|
||||
// tiers) this inset is the only thing keeping content clear of the system nav bar.
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
Route.Profile(accountViewModel.userProfile().pubkeyHex),
|
||||
|
||||
+9
-2
@@ -26,13 +26,17 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
@@ -197,7 +201,10 @@ private fun SearchBar(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.statusBarsPadding(),
|
||||
// A custom bar (not a Material3 TopAppBar) must apply the top system insets
|
||||
// itself. systemBars — not just statusBars — so the caption bar of a desktop
|
||||
// window (Waydroid/DeX freeform) is respected too.
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
|
||||
) {
|
||||
SearchTextField(searchBarViewModel, Modifier)
|
||||
// Inline Namecoin lookup feedback for the global search field.
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
+18
-7
@@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.Reward
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.observeEdits
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
|
||||
@@ -703,7 +704,10 @@ private fun FullBleedNoteCompose(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
if (zapSender != null) {
|
||||
UsernameDisplay(zapSender, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
@@ -736,14 +740,21 @@ private fun FullBleedNoteCompose(
|
||||
|
||||
CheckAndDisplayEditStatus(editState)
|
||||
|
||||
Expiration(baseNote)
|
||||
Expiration(baseNote, accountViewModel)
|
||||
|
||||
TimeAgo(note = baseNote)
|
||||
// The dotted TimeAgo carries its own " • " separator and the
|
||||
// options button has slack around its icon: keep the pair unspaced.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TimeAgo(note = baseNote, style = TimeAgoStyle.DottedTight)
|
||||
|
||||
MoreOptionsButton(baseNote, editState, accountViewModel, nav)
|
||||
MoreOptionsButton(baseNote, editState, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
@@ -765,12 +776,12 @@ private fun FullBleedNoteCompose(
|
||||
|
||||
val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } }
|
||||
if (baseReward != null) {
|
||||
DisplayReward(baseReward, baseNote, accountViewModel, nav)
|
||||
DisplayReward(baseReward, baseNote, accountViewModel)
|
||||
}
|
||||
|
||||
val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() }
|
||||
if (pow != null) {
|
||||
DisplayPoW(pow)
|
||||
DisplayPoW(pow, accountViewModel)
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
|
||||
@@ -2797,7 +2797,6 @@
|
||||
<string name="nsite_source">Zdroj:</string>
|
||||
<string name="nsite_servers">Servery:</string>
|
||||
<string name="nsite_open">Otevřít</string>
|
||||
<string name="existed_since">OTS: %1$s</string>
|
||||
<string name="ots_info_title">Důkaz časového razítka</string>
|
||||
<string name="ots_info_description">Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase.</string>
|
||||
<string name="edit_article">Upravit článek</string>
|
||||
|
||||
@@ -2683,7 +2683,6 @@
|
||||
<string name="nsite_source">Quelle:</string>
|
||||
<string name="nsite_servers">Server:</string>
|
||||
<string name="nsite_open">Öffnen</string>
|
||||
<string name="existed_since">OTS: %1$s</string>
|
||||
<string name="ots_info_title">Zeitstempel Beweis</string>
|
||||
<string name="ots_info_description">Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt.</string>
|
||||
<string name="edit_article">Artikel bearbeiten</string>
|
||||
|
||||
@@ -680,7 +680,6 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="forked_from">Geforkt von</string>
|
||||
<string name="git_web_address">Internet:</string>
|
||||
<string name="git_clone_address">Klonen:</string>
|
||||
<string name="existed_since">OTS: %1$s</string>
|
||||
<string name="ots_info_title">Zeitstempel Beweis</string>
|
||||
<string name="ots_info_description">Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt.</string>
|
||||
<string name="edit_post">Beitrag bearbeiten</string>
|
||||
|
||||
@@ -2125,7 +2125,6 @@
|
||||
<string name="nsite_source">Fonto:</string>
|
||||
<string name="nsite_servers">Serviloj:</string>
|
||||
<string name="nsite_open">Malfermi</string>
|
||||
<string name="existed_since">OTS: %1$s</string>
|
||||
<string name="ots_info_title">Tempomarko-Pruvo</string>
|
||||
<string name="ots_info_description">Estas pruvo ke ĉi tiu afiŝo estis subskribita iam antaŭ %1$s. La pruvo estis stampita en la Bitcoin-blokĉeno en tiu dato kaj horo.</string>
|
||||
<string name="edit_article">Redakti Artikolon</string>
|
||||
|
||||
@@ -2297,7 +2297,6 @@
|
||||
<string name="nsite_source">Fuente:</string>
|
||||
<string name="nsite_servers">Servidores:</string>
|
||||
<string name="nsite_open">Abrir</string>
|
||||
<string name="existed_since">OTS: %1$s</string>
|
||||
<string name="ots_info_title">Prueba de marca de tiempo</string>
|
||||
<string name="ots_info_description">Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora.</string>
|
||||
<string name="edit_article">Editar artículo</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user