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/notecompose-pow-pill-style-vr6hbt
# Conflicts: # commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf
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)
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import org.gradle.api.services.BuildService
|
||||
import org.gradle.api.services.BuildServiceParameters
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -342,6 +345,30 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// Gradle schedules Kotlin compilations of different variants of this module
|
||||
// concurrently (e.g. playDebug + playBenchmark when CI runs unit tests, lint,
|
||||
// and assembleBenchmark in one invocation), but they all share a single Kotlin
|
||||
// daemon whose heap (kotlin.daemon.jvmargs) cannot fit two full :amethyst
|
||||
// codegen passes — CI runs died with "GC overhead limit exceeded" inside the
|
||||
// daemon. This no-op shared build service with maxParallelUsages = 1 tells the
|
||||
// scheduler to run this module's Kotlin compile tasks one at a time; other
|
||||
// projects' tasks (JVM tests, lint analysis, packaging) still run in parallel.
|
||||
//
|
||||
// CI-only: the OOM needs a cache-cold compile of several variants at once,
|
||||
// which local builds (incremental, usually one variant) don't produce.
|
||||
abstract class AmethystKotlinCompileLimiter : BuildService<BuildServiceParameters.None>
|
||||
|
||||
if (System.getenv("CI") != null) {
|
||||
val kotlinCompileLimiter =
|
||||
gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) {
|
||||
maxParallelUsages.set(1)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
usesService(kotlinCompileLimiter)
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecr
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
@@ -478,6 +480,10 @@ class Account(
|
||||
val emoji = EmojiPackState(signer, cache, scope)
|
||||
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
|
||||
|
||||
// needs `emoji` above: nickname edits resolve :shortcodes: against the account's packs
|
||||
val contactCardDecryptionCache = ContactCardDecryptionCache(signer)
|
||||
val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, emoji)
|
||||
|
||||
val vanish = VanishRequestsState(signer, cache, client, scope)
|
||||
|
||||
val appSpecific = AppSpecificState(signer, cache, scope, settings)
|
||||
@@ -3814,6 +3820,18 @@ class Account(
|
||||
sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex))
|
||||
}
|
||||
|
||||
/**
|
||||
* Nicknames a user by publishing the account's kind:30382 contact card about
|
||||
* them, with the petname, summary and their custom emoji mappings NIP-44
|
||||
* encrypted in the content. `null` clears a field. Goes out through the
|
||||
* account's extended outbox relays.
|
||||
*/
|
||||
suspend fun updateContactCardPetName(
|
||||
pubkeyHex: HexKey,
|
||||
petName: String?,
|
||||
summary: String?,
|
||||
) = sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary))
|
||||
|
||||
suspend fun showUser(pubkeyHex: HexKey) {
|
||||
sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex))
|
||||
sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex))
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3216,10 +3216,28 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
live.removedNote(newNote)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-usage ledger hook: called with (elapsedNanos, valid) for every
|
||||
* signature verification so the app can account crypto CPU per day.
|
||||
* Wired by AppModules like [onchainBackend]; null costs nothing.
|
||||
*/
|
||||
@Volatile
|
||||
var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null
|
||||
|
||||
fun justVerify(event: Event): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
return if (!event.verify()) {
|
||||
val meter = verifyMeter
|
||||
if (meter == null) return justVerifyInner(event)
|
||||
|
||||
val start = System.nanoTime()
|
||||
val valid = justVerifyInner(event)
|
||||
meter(System.nanoTime() - start, valid)
|
||||
return valid
|
||||
}
|
||||
|
||||
private fun justVerifyInner(event: Event): Boolean =
|
||||
if (!event.verify()) {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
@@ -3230,7 +3248,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: DraftWrapEvent,
|
||||
@@ -3847,7 +3864,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
// A wrap with an empty content carries no NIP-44 ciphertext and can
|
||||
// never be unwrapped — reject it before paying for a signature check
|
||||
// and a cache slot. Locally stripped copies (copyNoContent) are
|
||||
// assigned straight to note.event and never pass through here.
|
||||
if (event.content.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
}
|
||||
|
||||
is GroupEvent -> {
|
||||
|
||||
+3
-1
@@ -64,6 +64,8 @@ class AccountCacheState(
|
||||
val client: INostrClient,
|
||||
val rootFilesDir: () -> File = { File("") },
|
||||
val powQueue: () -> PoWPublishQueue? = { null },
|
||||
/** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */
|
||||
val meterSigner: (NostrSigner) -> NostrSigner = { it },
|
||||
) {
|
||||
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
|
||||
|
||||
@@ -176,7 +178,7 @@ class AccountCacheState(
|
||||
|
||||
val signerWithClientTag =
|
||||
NostrSignerWithClientTag(
|
||||
inner = signer,
|
||||
inner = meterSigner(signer),
|
||||
clientName = CLIENT_TAG_NAME,
|
||||
disabled = { !accountSettings.syncedSettings.security.addClientTag.value },
|
||||
)
|
||||
|
||||
+20
-7
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model.privacyOptions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -32,7 +34,18 @@ import javax.net.SocketFactory
|
||||
class RoleBasedHttpClientBuilder(
|
||||
val okHttpClient: DualHttpClientManager,
|
||||
val torSettings: TorSettingsFlow,
|
||||
/**
|
||||
* When present, every role's client is wrapped with a byte-counting
|
||||
* interceptor so the resource-usage ledger can attribute HTTP traffic
|
||||
* per subsystem. Null keeps the raw shared clients (tests).
|
||||
*/
|
||||
val usageMeter: HttpUsageMeter? = null,
|
||||
) : IRoleBasedHttpClientBuilder {
|
||||
private fun metered(
|
||||
role: String,
|
||||
base: OkHttpClient,
|
||||
): OkHttpClient = usageMeter?.counted(role, base) ?: base
|
||||
|
||||
fun shouldUseTorForImageDownload(url: String) =
|
||||
shouldUseTorFor(
|
||||
url,
|
||||
@@ -131,19 +144,19 @@ class RoleBasedHttpClientBuilder(
|
||||
|
||||
override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url))
|
||||
|
||||
override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url))
|
||||
override fun okHttpClientForNip05(url: String): OkHttpClient = metered(UsageKeys.ROLE_NIP05, okHttpClient.getHttpClient(shouldUseTorForNIP05(url)))
|
||||
|
||||
override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url))
|
||||
override fun okHttpClientForUploads(url: String): OkHttpClient = metered(UsageKeys.ROLE_UPLOADS, okHttpClient.getHttpClient(shouldUseTorForUploads(url)))
|
||||
|
||||
override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))
|
||||
override fun okHttpClientForImage(url: String): OkHttpClient = metered(UsageKeys.ROLE_IMAGE, okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)))
|
||||
|
||||
override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))
|
||||
override fun okHttpClientForVideo(url: String): OkHttpClient = metered(UsageKeys.ROLE_VIDEO, okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)))
|
||||
|
||||
override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))
|
||||
override fun okHttpClientForMoney(url: String): OkHttpClient = metered(UsageKeys.ROLE_MONEY, okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)))
|
||||
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = metered(UsageKeys.ROLE_PREVIEW, okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)))
|
||||
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = metered(UsageKeys.ROLE_PUSH, okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()))
|
||||
|
||||
/**
|
||||
* Returns a [SocketFactory] that routes through the user's Tor proxy
|
||||
|
||||
+60
-7
@@ -26,9 +26,11 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
@@ -47,15 +49,26 @@ import java.util.concurrent.TimeUnit
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*
|
||||
* The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP
|
||||
* observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and
|
||||
* [doWork] cancels the chain when the cache holds no accepted RSVP that could still start.
|
||||
* LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty
|
||||
* cache and can never fire a reminder — an unconditional periodic schedule would cold-start
|
||||
* the whole app graph every 15 minutes forever for zero benefit.
|
||||
*/
|
||||
class CalendarReminderWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) }
|
||||
val prefs = CalendarReminderPrefs(applicationContext)
|
||||
if (!prefs.isEnabled()) {
|
||||
Log.d(TAG) { "Reminders disabled; skipping scan." }
|
||||
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
|
||||
// The settings toggle re-schedules on enable; no reason to keep
|
||||
// waking the process while the feature is off.
|
||||
cancel(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
@@ -66,12 +79,7 @@ class CalendarReminderWorker(
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
// present in cache — the alternative (looking only at the foreground account) would
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
val acceptedRsvps = acceptedRsvpsInCache()
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
|
||||
@@ -110,6 +118,22 @@ class CalendarReminderWorker(
|
||||
|
||||
// Prune entries for events that ended more than a day ago — they can't fire again.
|
||||
store.forgetBefore(now - PRUNE_AGE_SECONDS)
|
||||
|
||||
// Nothing left that could ever fire → end the periodic chain instead of
|
||||
// waking the process every 15 minutes forever. The observers in
|
||||
// AppModules re-schedule the worker the next time a live session sees
|
||||
// an accepted RSVP or a calendar-event update.
|
||||
//
|
||||
// Decide on a FRESH cache snapshot, not the one from the start of the
|
||||
// run: an RSVP accepted while this run was scanning already fired the
|
||||
// observer, whose schedule() uses KEEP and no-ops while this chain
|
||||
// still exists — cancelling on the stale snapshot would kill the chain
|
||||
// with that RSVP's reminder permanently lost (observeNewEvents never
|
||||
// re-fires for an event that is already in cache).
|
||||
if (!couldStillFire(acceptedRsvpsInCache(), TimeUtils.now())) {
|
||||
Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." }
|
||||
cancel(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@@ -121,6 +145,35 @@ class CalendarReminderWorker(
|
||||
// more than a day ago; they can't fire again so the entry is pure overhead.
|
||||
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
|
||||
|
||||
/** Every ACCEPTED kind-31925 RSVP currently present in LocalCache. */
|
||||
fun acceptedRsvpsInCache(): List<CalendarRSVPEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
/**
|
||||
* True while at least one accepted RSVP could still produce a reminder:
|
||||
* its target event either starts in the future, or hasn't been fetched
|
||||
* yet (start unknown — the chain must survive until the target
|
||||
* resolves). False means the periodic worker has nothing it could ever
|
||||
* notify about and may cancel its own chain.
|
||||
*/
|
||||
fun couldStillFire(
|
||||
rsvps: Collection<CalendarRSVPEvent>,
|
||||
now: Long,
|
||||
): Boolean =
|
||||
rsvps.any { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@any false
|
||||
val start =
|
||||
LocalCache.addressables
|
||||
.get(targetAddress)
|
||||
?.appointmentView()
|
||||
?.startSeconds
|
||||
start == null || start > now
|
||||
}
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
|
||||
|
||||
+3
@@ -34,6 +34,7 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
@@ -61,6 +62,7 @@ class CallForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.callSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
@@ -151,6 +153,7 @@ class CallForegroundService : Service() {
|
||||
// because CallManager.hangup() transitions to Ended and a second
|
||||
// hangup() from Ended state returns immediately.
|
||||
publishHangupBlocking()
|
||||
Amethyst.instance.callSession.setActive(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.crashreports
|
||||
|
||||
/**
|
||||
* Developer recipient for every user-initiated diagnostic NIP-17 DM — crash
|
||||
* reports and resource-usage reports both route here. Single definition so a
|
||||
* key rotation can never leave one path DMing the old key.
|
||||
*/
|
||||
const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"
|
||||
+1
-1
@@ -80,7 +80,7 @@ fun DisplayCrashMessages(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser("aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"),
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = stack,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
|
||||
@@ -31,13 +31,17 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
class LocationState(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
|
||||
private val onListening: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 10000L
|
||||
@@ -72,6 +76,8 @@ class LocationState(
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
|
||||
}.onEach {
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
|
||||
@@ -106,6 +107,7 @@ class NestForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.nestsSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
wakeLock =
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
@@ -421,6 +423,7 @@ class NestForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Amethyst.instance.nestsSession.setActive(false)
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
wakeLock = null
|
||||
abandonAudioFocus()
|
||||
|
||||
+5
@@ -24,6 +24,7 @@ import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
@@ -108,6 +109,8 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
|
||||
|
||||
class EventNotificationConsumer(
|
||||
private val applicationContext: Context,
|
||||
/** Reports how long each notification-processing wakelock was held (resource-usage ledger). */
|
||||
private val onWakeLockHeld: ((heldMs: Long) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes
|
||||
@@ -126,6 +129,7 @@ class EventNotificationConsumer(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"amethyst:notification_processing",
|
||||
)
|
||||
val heldSince = SystemClock.elapsedRealtime()
|
||||
wakeLock.acquire(WAKELOCK_TIMEOUT_MS)
|
||||
try {
|
||||
return block()
|
||||
@@ -133,6 +137,7 @@ class EventNotificationConsumer(
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
onWakeLockHeld?.invoke(SystemClock.elapsedRealtime() - heldSince)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -120,6 +121,7 @@ class NotificationCatchUpWorker(
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.d(TAG, "Starting notification catch-up")
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("notificationCatchUp"), 1) }
|
||||
|
||||
return try {
|
||||
// If the foreground service should be running but isn't, restart it
|
||||
|
||||
+3
-1
@@ -80,6 +80,8 @@ import kotlinx.coroutines.launch
|
||||
class NotificationDispatcher(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
/** Forwarded to [EventNotificationConsumer]: reports wakelock held-time to the resource-usage ledger. */
|
||||
onWakeLockHeld: ((heldMs: Long) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NotificationDispatcher"
|
||||
@@ -127,7 +129,7 @@ class NotificationDispatcher(
|
||||
)
|
||||
}
|
||||
|
||||
private val consumer = EventNotificationConsumer(context)
|
||||
private val consumer = EventNotificationConsumer(context, onWakeLockHeld)
|
||||
private var job: Job? = null
|
||||
|
||||
fun start() {
|
||||
|
||||
+2
@@ -138,6 +138,7 @@ class NotificationRelayService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
Amethyst.instance.alwaysOnSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
ensureForeground()
|
||||
}
|
||||
@@ -197,6 +198,7 @@ class NotificationRelayService : Service() {
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Service destroyed")
|
||||
Amethyst.instance.alwaysOnSession.setActive(false)
|
||||
relayServiceCollectorJob?.cancel()
|
||||
scope.cancel()
|
||||
|
||||
|
||||
+6
-1
@@ -52,8 +52,13 @@ class ServiceWatchdogManager {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// ELAPSED_REALTIME (not _WAKEUP): a health check is not worth pulling
|
||||
// the CPU out of sleep — if the device is asleep, a restarted service
|
||||
// couldn't do useful network work anyway. The alarm fires as soon as
|
||||
// the device is next awake, and the deeper restart layers (15-min
|
||||
// WorkManager job, FCM/UnifiedPush) cover the force-stop/doze cases.
|
||||
alarmManager.setInexactRepeating(
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
AlarmManager.ELAPSED_REALTIME,
|
||||
SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS,
|
||||
WATCHDOG_INTERVAL_MS,
|
||||
pendingIntent,
|
||||
|
||||
+5
-1
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.Call
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.InetSocketAddress
|
||||
@@ -46,8 +47,11 @@ class DualHttpClientManager(
|
||||
// is uniform across image, upload, NIP-05, money, preview, and push roles.
|
||||
// See [OkHttpClientFactory] kdoc.
|
||||
onionCache: OnionLocationCache,
|
||||
// Resource-usage ledger counter, installed on the shared base client so
|
||||
// every derived client is accounted. See [OkHttpClientFactory].
|
||||
usageInterceptor: Interceptor? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+9
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
@@ -61,6 +62,13 @@ class OkHttpClientFactory(
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
private val onionCache: OnionLocationCache,
|
||||
/**
|
||||
* Resource-usage ledger counter, installed OUTERMOST on the shared base
|
||||
* client so every request through any derived client is accounted —
|
||||
* per-role tagging wrappers only relabel, they never bypass. Null in
|
||||
* tests / pre-configuration call sites.
|
||||
*/
|
||||
private val usageInterceptor: Interceptor? = null,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
@@ -103,6 +111,7 @@ class OkHttpClientFactory(
|
||||
.pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.apply { usageInterceptor?.let { addInterceptor(it) } }
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.apply {
|
||||
blossomCacheRedirect?.let { addInterceptor(it) }
|
||||
|
||||
+13
@@ -40,6 +40,19 @@ class OkHttpClientFactoryForRelays(
|
||||
const val DEFAULT_IS_MOBILE: Boolean = false
|
||||
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
|
||||
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
|
||||
|
||||
// 120s is a measured sweet spot, not a guess — see
|
||||
// amethyst/plans/2026-07-12-relay-ping-interval-study.md (probe of 122
|
||||
// production relays). Idle-timeout tiers observed in production are
|
||||
// ~60s / ~120s / ~240s / ~300s / ~600s, and a client ping only reliably
|
||||
// resets a relay's idle timer when the interval sits well BELOW the
|
||||
// tier (240s pings already lose the ~240s and ~300s tiers, incl. every
|
||||
// nostr1.com-hosted relay; 300s pings lose relay.snort.social). Raising
|
||||
// the interval also saves almost no battery: 90% of surveyed relays
|
||||
// send their own server pings every 30-70s, which OkHttp must answer,
|
||||
// so the radio's wake cadence is set by the relays, not by this value.
|
||||
// Lowering it would only rescue the ~120s tier (6/122 relays, already
|
||||
// cycling today) at 2x the ping traffic on every other connection.
|
||||
const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.repeat.AutoReplayLimiter
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@@ -82,8 +83,10 @@ class ExoPlayerBuilder(
|
||||
)
|
||||
PcmTapRegistry.bind(currentMediaItem?.mediaId, sink)
|
||||
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.repeat
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
|
||||
/**
|
||||
* Caps how many times a video loops on its own under [Player.REPEAT_MODE_ONE].
|
||||
*
|
||||
* Feed videos are configured to repeat forever (keepPlaying → REPEAT_MODE_ONE in
|
||||
* PlaybackService), which keeps decoders, network and the screen busy long after the
|
||||
* user stopped watching. This listener lets a video play [maxAutoPlays] full times and
|
||||
* then pauses it, so continuing requires an explicit press of the play button.
|
||||
*
|
||||
* Each loop under REPEAT_MODE_ONE surfaces as an [onMediaItemTransition] with
|
||||
* [Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT], marking one completed play. The
|
||||
* transition has already seeked back to the start, so pausing there leaves the video
|
||||
* on its first frame with the play button showing. Any resume — the user pressing
|
||||
* play, or the feed mutex auto-playing when the video scrolls back to the center —
|
||||
* grants a fresh allowance, and switching to a different media item resets it too.
|
||||
*/
|
||||
class AutoReplayLimiter(
|
||||
val maxAutoPlays: Int = DEFAULT_MAX_AUTO_PLAYS,
|
||||
val pause: () -> Unit,
|
||||
) : Player.Listener {
|
||||
private var playsCompleted = 0
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) {
|
||||
playsCompleted++
|
||||
if (playsCompleted >= maxAutoPlays) {
|
||||
pause()
|
||||
}
|
||||
} else {
|
||||
playsCompleted = 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(
|
||||
playWhenReady: Boolean,
|
||||
reason: Int,
|
||||
) {
|
||||
if (playWhenReady) {
|
||||
playsCompleted = 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_AUTO_PLAYS = 5
|
||||
}
|
||||
}
|
||||
+8
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay
|
||||
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
@@ -124,6 +125,13 @@ fun filterAccountInfoAndListsFromKey(
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
// The account's own kind:30382 contact cards (nicknames, NIP-44 encrypted).
|
||||
// Addressable — one card per target user — hence its own larger-limit filter.
|
||||
filterContactCardsByAuthorInTheRelay(
|
||||
relay = relay,
|
||||
author = pubkey,
|
||||
since = since,
|
||||
),
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
|
||||
+24
-11
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.Nickname
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
@@ -43,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -59,18 +61,29 @@ fun observeUserName(
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.metadata()
|
||||
.flow
|
||||
.map {
|
||||
it?.info?.bestName() ?: user.pubkeyDisplayHex()
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
val contactCards = accountViewModel.account.contactCards
|
||||
val flow = remember(user) { contactCards.displayNameFlow(user) }
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
|
||||
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) })
|
||||
}
|
||||
|
||||
/**
|
||||
* The nickname (NIP-85 petname + private summary) the logged-in account gave
|
||||
* this user through its own contact card, decrypted from the card's content,
|
||||
* with the card's tags so `:shortcode:` custom emojis resolve. Null when the
|
||||
* account never nicknamed this user. Per the spec, the petname should be
|
||||
* rendered instead of the user's display name.
|
||||
*/
|
||||
@Composable
|
||||
fun observeUserNickname(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<Nickname?> {
|
||||
val contactCards = accountViewModel.account.contactCards
|
||||
val flow = remember(user) { contactCards.nicknameFlow(user) }
|
||||
|
||||
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) })
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -296,7 +309,7 @@ fun observeUserBookmarkCount(
|
||||
|
||||
val combined =
|
||||
remember(user) {
|
||||
kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount ->
|
||||
combine(newFlow, oldFlow) { newCount, oldCount ->
|
||||
newCount + oldCount
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.toHexSet
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -32,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
class UserCardsSubAssembler(
|
||||
@@ -45,7 +47,10 @@ class UserCardsSubAssembler(
|
||||
filters: List<Filter>?,
|
||||
) {
|
||||
filters?.forEach { filter ->
|
||||
filter.tags?.get("p")?.forEach {
|
||||
// kind:30382 addresses the target user in the d-tag (the key the
|
||||
// filter builder uses). Reading any other tag leaves the per-user
|
||||
// EOSEs unset and forces full re-downloads with since = null.
|
||||
filter.tags?.get(DTag.TAG_NAME)?.forEach {
|
||||
val targetUser = cache.getUserIfExists(it)
|
||||
targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time)
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
@@ -298,14 +299,16 @@ fun RenderUserAsClickableText(
|
||||
nav: INav,
|
||||
) {
|
||||
val userState by observeUserInfo(baseUser, accountViewModel)
|
||||
val nickname by observeUserNickname(baseUser, accountViewModel)
|
||||
val petName = nickname?.petName
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = "@" + (userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
|
||||
clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
|
||||
suffix = additionalChars?.ifBlank { null },
|
||||
maxLines = 1,
|
||||
route = remember(baseUser) { routeFor(baseUser) },
|
||||
nav = nav,
|
||||
tags = userState?.tags ?: EmptyTagList,
|
||||
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
@@ -1010,15 +1011,17 @@ private fun DisplayUserFromTag(
|
||||
nav: INav,
|
||||
) {
|
||||
val meta by observeUserInfo(baseUser, accountViewModel)
|
||||
val nickname by observeUserNickname(baseUser, accountViewModel)
|
||||
val petName = nickname?.petName
|
||||
|
||||
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) {
|
||||
Row {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = remember(meta) { it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
|
||||
clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
|
||||
maxLines = 1,
|
||||
route = remember(baseUser) { routeFor(baseUser) },
|
||||
nav = nav,
|
||||
tags = it?.tags,
|
||||
tags = if (petName != null) nickname?.tags else it?.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,16 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
|
||||
@@ -228,6 +232,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettin
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ResourceUsageScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen
|
||||
@@ -301,11 +306,13 @@ fun AppNavigation(
|
||||
}
|
||||
}
|
||||
|
||||
TrackScreenTime(nav)
|
||||
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
|
||||
|
||||
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
|
||||
DisplayNotifyMessages(accountViewModel, nav)
|
||||
DisplayCrashMessages(accountViewModel, nav)
|
||||
DisplayResourceUsageAlert(accountViewModel, nav)
|
||||
DisplayBroadcastProgress(accountViewModel)
|
||||
|
||||
ObserveIncomingCalls(accountViewModel)
|
||||
@@ -324,6 +331,27 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the resource-usage ledger with time-per-screen. Only the route's
|
||||
* base name crosses this boundary — [ScreenTimeIntegrator.screenNameOf]
|
||||
* strips every navigation argument first, so the ledger can say "Profile"
|
||||
* but never which profile.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrackScreenTime(nav: Nav) {
|
||||
DisposableEffect(nav.controller) {
|
||||
val listener =
|
||||
NavController.OnDestinationChangedListener { _, destination, _ ->
|
||||
Amethyst.instance.screenTime.onScreen(ScreenTimeIntegrator.screenNameOf(destination.route))
|
||||
}
|
||||
nav.controller.addOnDestinationChangedListener(listener)
|
||||
onDispose {
|
||||
nav.controller.removeOnDestinationChangedListener(listener)
|
||||
Amethyst.instance.screenTime.onScreen(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BuildNavigation(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -481,6 +509,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ResourceUsage> { ResourceUsageScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ImportFollowsPickFollows> {
|
||||
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
|
||||
|
||||
+2
-2
@@ -237,14 +237,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,6 +422,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object CalendarReminderSettings : Route()
|
||||
|
||||
@Serializable object ResourceUsage : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.topbars
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.MemorySnapshot
|
||||
import com.vitorpamplona.amethyst.collectMemorySnapshot
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun MemoryUsageChip() {
|
||||
if (!isDebug) return
|
||||
|
||||
val context = LocalContext.current
|
||||
var showDetail by remember { mutableStateOf(false) }
|
||||
|
||||
val snapshot by produceState<MemorySnapshot?>(null) {
|
||||
while (true) {
|
||||
// collectMemorySnapshot reads coil3.disk.DiskLruCache.size(), which is @Synchronized and
|
||||
// contends with the disk cache's own journal I/O. On cold start that lock is held by a
|
||||
// background worker for seconds (initial journal read + the burst of image writes), so
|
||||
// running this on the produceState default (main) dispatcher froze the UI thread —
|
||||
// the "Loading account" frame couldn't repaint until size() returned. Collect off-main.
|
||||
value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) }
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
|
||||
val s = snapshot ?: return
|
||||
|
||||
val chipColor =
|
||||
when {
|
||||
s.heapFraction > 0.80f -> Color(0xFFE53935) // red
|
||||
s.heapFraction > 0.60f -> Color(0xFFFFA000) // amber
|
||||
else -> Color(0xFF43A047) // green
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${s.heapUsedMb}/${s.heapMaxMb}MB",
|
||||
color = chipColor,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.clickable { showDetail = true },
|
||||
)
|
||||
|
||||
if (showDetail) {
|
||||
MemoryDetailDialog(snapshot = s, onDismiss = { showDetail = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemoryDetailDialog(
|
||||
snapshot: MemorySnapshot,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Memory Usage") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Device class: ${snapshot.memoryClassMb} MB")
|
||||
Text("JVM heap: ${snapshot.heapUsedMb} / ${snapshot.heapMaxMb} MB")
|
||||
Text("Native heap: ${snapshot.nativeHeapUsedMb} MB")
|
||||
Text("Coil memory: ${snapshot.imageCacheUsedMb} / ${snapshot.imageCacheMaxMb} MB")
|
||||
Text("Coil disk: ${snapshot.imageDiskUsedMb} / ${snapshot.imageDiskMaxMb} MB")
|
||||
Text("Notes: ${snapshot.noteCount}")
|
||||
Text("Users: ${snapshot.userCount}")
|
||||
Text("Addressables: ${snapshot.addressableCount}")
|
||||
Text("Chatrooms: ${snapshot.chatroomCount}")
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("OK") }
|
||||
},
|
||||
)
|
||||
}
|
||||
-1
@@ -75,7 +75,6 @@ fun UserDrawerSearchTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
MemoryUsageChip()
|
||||
IconButton(onClick = { nav.nav(Route.Search) }) {
|
||||
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
|
||||
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
@@ -105,11 +106,15 @@ fun UsernameDisplay(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val userMetadata by observeUserInfo(baseUser, accountViewModel)
|
||||
val nickname by observeUserNickname(baseUser, accountViewModel)
|
||||
|
||||
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
|
||||
val name = it?.info?.bestName()
|
||||
// the account's own nickname for this user wins over the user's metadata;
|
||||
// its custom emojis resolve against the contact card's tags, not the profile's
|
||||
val petName = nickname?.petName
|
||||
val name = petName ?: it?.info?.bestName()
|
||||
if (name != null) {
|
||||
UserDisplay(name, it.tags, weight, fontWeight, textColor, textAlign)
|
||||
UserDisplay(name, if (petName != null) nickname?.tags else it?.tags, weight, fontWeight, textColor, textAlign)
|
||||
} else {
|
||||
NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign)
|
||||
}
|
||||
|
||||
+15
-6
@@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -49,7 +50,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -58,8 +62,9 @@ fun Notifying(
|
||||
accountViewModel: AccountViewModel,
|
||||
label: String? = null,
|
||||
showWhenEmpty: Boolean = false,
|
||||
mutedNotifies: ImmutableSet<HexKey> = persistentSetOf(),
|
||||
onAddUser: (() -> Unit)? = null,
|
||||
onClick: (User) -> Unit,
|
||||
onToggleNotify: (User) -> Unit,
|
||||
) {
|
||||
val mentions = baseMentions?.toSet()
|
||||
|
||||
@@ -83,7 +88,7 @@ fun Notifying(
|
||||
// spacing matches the horizontal spacing between chips.
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
mentions?.forEach { user ->
|
||||
NotifyUserChip(user, accountViewModel) { onClick(user) }
|
||||
NotifyUserChip(user, user.pubkeyHex in mutedNotifies, accountViewModel) { onToggleNotify(user) }
|
||||
}
|
||||
|
||||
if (onAddUser != null) {
|
||||
@@ -97,12 +102,16 @@ fun Notifying(
|
||||
@Composable
|
||||
private fun NotifyUserChip(
|
||||
user: User,
|
||||
isMuted: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRemove: () -> Unit,
|
||||
onToggleNotify: () -> Unit,
|
||||
) {
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = onRemove,
|
||||
onClick = onToggleNotify,
|
||||
// The bell-off icon alone is easy to miss at chip size, so a muted member
|
||||
// also fades as a second cue while staying in the list for easy re-adding.
|
||||
modifier = if (isMuted) Modifier.alpha(0.4f) else Modifier,
|
||||
label = {
|
||||
UsernameDisplay(
|
||||
user,
|
||||
@@ -119,8 +128,8 @@ private fun NotifyUserChip(
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(R.string.notify_remove_user),
|
||||
symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications,
|
||||
contentDescription = stringRes(if (isMuted) R.string.notify_unmute_user else R.string.notify_mute_user),
|
||||
modifier = Modifier.size(InputChipDefaults.IconSize),
|
||||
)
|
||||
},
|
||||
|
||||
+31
-36
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
@@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
@@ -96,8 +97,6 @@ import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -177,6 +176,21 @@ open class CommentPostViewModel :
|
||||
|
||||
var notifying by mutableStateOf<List<User>?>(null)
|
||||
|
||||
// Members of the notifying list whose bell is off: they keep their chip
|
||||
// (so they are one tap away from being added back) but are dropped from
|
||||
// the extra notification p tags of the outgoing comment.
|
||||
var mutedNotifies by mutableStateOf<Set<HexKey>>(emptySet())
|
||||
|
||||
fun toggleNotify(user: User) {
|
||||
mutedNotifies =
|
||||
if (user.pubkeyHex in mutedNotifies) {
|
||||
mutedNotifies - user.pubkeyHex
|
||||
} else {
|
||||
mutedNotifies + user.pubkeyHex
|
||||
}
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
// NIP-9B: latest community rules document for the community we're posting into.
|
||||
// Null when the reply target is not a community, or no rules have been observed yet.
|
||||
var communityRules: CommunityRulesEvent? by mutableStateOf(null)
|
||||
@@ -275,7 +289,7 @@ open class CommentPostViewModel :
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
}
|
||||
|
||||
fun newPostFor(externalIdentity: ExternalId) {
|
||||
@@ -303,6 +317,7 @@ open class CommentPostViewModel :
|
||||
open fun reply(post: Note) {
|
||||
this.replyingTo = post
|
||||
this.externalIdentity = (post.event as? CommentEvent)?.scope()
|
||||
mutedNotifies = emptySet()
|
||||
(post.event as? LnZapEvent)?.let { zap ->
|
||||
notifying = listOfNotNull(zapSenderToNotify(zap))
|
||||
}
|
||||
@@ -498,14 +513,16 @@ open class CommentPostViewModel :
|
||||
|
||||
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
|
||||
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
mutedNotifies = emptySet()
|
||||
|
||||
// Replies to zaps notify the zap sender through a plain p tag (the receipt's
|
||||
// author keys above are the lightning provider). Restore the sender chip only
|
||||
// if the draft still tags them — its absence means the user removed it.
|
||||
// author keys above are the lightning provider). The sender chip always comes
|
||||
// back; a missing p tag in the draft means the user muted their bell.
|
||||
(replyingTo?.event as? LnZapEvent)?.let { zap ->
|
||||
zapSenderToNotify(zap)?.let { sender ->
|
||||
if (draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) {
|
||||
notifying = (notifying ?: emptyList()) + sender
|
||||
notifying = ((notifying ?: emptyList()) + sender).distinct()
|
||||
if (!draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) {
|
||||
mutedNotifies = mutedNotifies + sender.pubkeyHex
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -626,7 +643,7 @@ open class CommentPostViewModel :
|
||||
|
||||
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
|
||||
|
||||
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
|
||||
val emojis = account.emoji.findEmojiTags(tagger.message)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
@@ -665,9 +682,9 @@ open class CommentPostViewModel :
|
||||
}
|
||||
} else if (replyingToEvent is LnZapEvent) {
|
||||
val sender = zapSenderToNotify(replyingToEvent)
|
||||
// notifying starts with the sender; a missing entry means the
|
||||
// user removed the chip, so respect that and don't tag them.
|
||||
if (sender != null && notifying?.contains(sender) != false) {
|
||||
// The sender's chip stays in the list; a muted bell means
|
||||
// the user doesn't want to ping them, so don't tag them.
|
||||
if (sender != null && sender.pubkeyHex !in mutedNotifies) {
|
||||
listOf(sender.toPTag())
|
||||
} else {
|
||||
emptyList()
|
||||
@@ -717,21 +734,6 @@ open class CommentPostViewModel :
|
||||
return template
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let {
|
||||
EmojiUrlTag(
|
||||
it.code,
|
||||
it.link,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
@@ -855,6 +857,7 @@ open class CommentPostViewModel :
|
||||
mediaUploadTracker.finishUpload()
|
||||
|
||||
notifying = null
|
||||
mutedNotifies = emptySet()
|
||||
|
||||
wantsInvoice = false
|
||||
wantsZapraiser = false
|
||||
@@ -886,10 +889,6 @@ open class CommentPostViewModel :
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
notifying = notifying?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
revalidateDraft()
|
||||
@@ -937,13 +936,9 @@ open class CommentPostViewModel :
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -52,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
@@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
|
||||
@@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -257,8 +258,12 @@ private fun GenericCommentPostBody(
|
||||
}
|
||||
|
||||
Row {
|
||||
Notifying(postViewModel.notifying?.toImmutableList(), accountViewModel) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
Notifying(
|
||||
baseMentions = postViewModel.notifying?.toImmutableList(),
|
||||
accountViewModel = accountViewModel,
|
||||
mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(),
|
||||
) {
|
||||
postViewModel.toggleNotify(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -85,7 +83,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.repoHasFetchableClo
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
@@ -93,7 +90,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size8dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
@@ -102,9 +98,7 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
private val CardShape = QuoteBorder
|
||||
private val ChipShape = RoundedCornerShape(8.dp)
|
||||
private val CardPadding = PaddingValues(start = Size10dp, top = Size10dp, end = Size10dp, bottom = Size5dp)
|
||||
private val HeaderSpacing = Arrangement.spacedBy(Size8dp)
|
||||
private val LinkRowSpacing = Arrangement.spacedBy(Size8dp)
|
||||
|
||||
@@ -113,15 +107,7 @@ private fun GitCardContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val border = MaterialTheme.colorScheme.subtleBorder
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.border(1.dp, border, CardShape)
|
||||
.padding(CardPadding),
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -261,12 +247,31 @@ private fun GitMetaRow(
|
||||
/** Shortens a 40-char git object id to its 7-char prefix for display. */
|
||||
private fun String.shortCommit(): String = if (length > 7) take(7) else this
|
||||
|
||||
/**
|
||||
* Some NIP-34 clients duplicate the subject tag as a leading markdown heading of the
|
||||
* content ("# Title\n\n…"). The card already renders the subject as its own title, so
|
||||
* when the first line is a heading matching [subject], drop it from the body.
|
||||
*/
|
||||
private fun stripDuplicatedSubject(
|
||||
content: String,
|
||||
subject: String?,
|
||||
): String {
|
||||
if (subject.isNullOrBlank()) return content
|
||||
val trimmed = content.trimStart()
|
||||
if (!trimmed.startsWith("#")) return content
|
||||
val firstLineEnd = trimmed.indexOf('\n').let { if (it < 0) trimmed.length else it }
|
||||
val heading = trimmed.substring(0, firstLineEnd).trimStart('#').trim()
|
||||
if (!heading.equals(subject.trim(), ignoreCase = true)) return content
|
||||
return trimmed.substring(firstLineEnd).trimStart()
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared markdown body used by patches, issues and pull requests: honors
|
||||
* the collapsed [makeItShort] preview for the logged-in user's own posts and
|
||||
* otherwise renders the full content with sensitivity warnings and uncited
|
||||
* hashtags. The subject, when present, is rendered separately as a title by the
|
||||
* caller, so it is not inlined here.
|
||||
* caller, so it is not inlined here — pass it as [renderedSubject] so a copy
|
||||
* duplicated into the content as a leading heading is stripped.
|
||||
*/
|
||||
@Composable
|
||||
private fun GitMarkdownBody(
|
||||
@@ -277,8 +282,10 @@ private fun GitMarkdownBody(
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
renderedSubject: String? = null,
|
||||
) {
|
||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
||||
LoadDecryptedContent(note, accountViewModel) { rawBody ->
|
||||
val body = remember(rawBody, renderedSubject) { stripDuplicatedSubject(rawBody, renderedSubject) }
|
||||
val isAuthorTheLoggedUser =
|
||||
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
@@ -537,7 +544,7 @@ private fun RenderGitIssueEvent(
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav, renderedSubject = subject)
|
||||
|
||||
if (!makeItShort) {
|
||||
GitStatusActions(note, accountViewModel)
|
||||
@@ -669,7 +676,7 @@ private fun RenderGitPullRequestEvent(
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav, renderedSubject = subject)
|
||||
|
||||
if (!makeItShort) {
|
||||
GitPullRequestChanges(cloneUrls, currentCommit, mergeBase, accountViewModel)
|
||||
|
||||
+14
-6
@@ -99,9 +99,13 @@ fun GitPullRequestChanges(
|
||||
|
||||
when (val s = state) {
|
||||
ChangesState.Idle ->
|
||||
FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { load() },
|
||||
modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight),
|
||||
contentPadding = CompactButtonPadding,
|
||||
) {
|
||||
Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringRes(R.string.git_pr_view_changes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
|
||||
ChangesState.Loading ->
|
||||
@@ -129,9 +133,13 @@ fun GitPullRequestChanges(
|
||||
}
|
||||
|
||||
ChangesState.Failed ->
|
||||
FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { load() },
|
||||
modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight),
|
||||
contentPadding = CompactButtonPadding,
|
||||
) {
|
||||
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringRes(R.string.git_pr_changes_retry), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-8
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -55,6 +57,10 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
private enum class StatusTarget { OPEN, CLOSED, APPLIED }
|
||||
|
||||
/** Compact sizing shared by the small action buttons on git cards. */
|
||||
internal val CompactButtonHeight = Modifier.height(32.dp)
|
||||
internal val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
|
||||
|
||||
/**
|
||||
* NIP-34 status controls for an issue, patch or pull request. Visible only to the
|
||||
* people allowed to moderate the thread — the item's author and the repository
|
||||
@@ -85,23 +91,33 @@ fun GitStatusActions(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (closedOrApplied) {
|
||||
FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) {
|
||||
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_status_reopen), modifier = Modifier.padding(start = 6.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) },
|
||||
modifier = CompactButtonHeight,
|
||||
contentPadding = CompactButtonPadding,
|
||||
) {
|
||||
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringRes(R.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
} else {
|
||||
if (isPatchOrPr) {
|
||||
FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }) {
|
||||
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_status_mark_merged), modifier = Modifier.padding(start = 6.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) },
|
||||
modifier = CompactButtonHeight,
|
||||
contentPadding = CompactButtonPadding,
|
||||
) {
|
||||
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringRes(R.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = CompactButtonHeight,
|
||||
contentPadding = CompactButtonPadding,
|
||||
) {
|
||||
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_status_close), modifier = Modifier.padding(start = 6.dp))
|
||||
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringRes(R.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -1704,6 +1704,12 @@ class AccountViewModel(
|
||||
|
||||
fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) }
|
||||
|
||||
fun updateContactCardPetName(
|
||||
user: User,
|
||||
petName: String?,
|
||||
summary: String?,
|
||||
) = launchSigner { account.updateContactCardPetName(user.pubkeyHex, petName, summary) }
|
||||
|
||||
fun hide(word: String) = launchSigner { account.hideWord(word) }
|
||||
|
||||
fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) }
|
||||
|
||||
+2
-15
@@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayA
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -172,20 +172,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
|
||||
val externalSignerLauncher =
|
||||
when (val signer = accountViewModel.account.signer) {
|
||||
is IActivityLauncher -> {
|
||||
signer
|
||||
}
|
||||
|
||||
is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> {
|
||||
signer.inner as IActivityLauncher
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
val externalSignerLauncher = accountViewModel.account.signer.innermostSigner() as? IActivityLauncher
|
||||
|
||||
if (externalSignerLauncher != null) {
|
||||
val launcher =
|
||||
|
||||
+8
-5
@@ -105,11 +105,14 @@ fun CalendarReminderSettingsScreen(nav: INav) {
|
||||
onCheckedChange = {
|
||||
enabled = it
|
||||
prefs.setEnabled(it)
|
||||
// Toggling off doesn't cancel the worker — the worker itself short-
|
||||
// circuits when isEnabled() returns false. Keeping the schedule alive
|
||||
// means flipping it back on takes effect immediately without needing a
|
||||
// re-launch via AppModules.
|
||||
if (it) CalendarReminderWorker.schedule(context)
|
||||
// Cancel eagerly on disable so the periodic worker stops waking the
|
||||
// process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP
|
||||
// observer in AppModules re-schedules on the next relevant RSVP too.
|
||||
if (it) {
|
||||
CalendarReminderWorker.schedule(context)
|
||||
} else {
|
||||
CalendarReminderWorker.cancel(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+6
-3
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.FollowingIcon
|
||||
@@ -58,13 +59,15 @@ private fun WatchAndDisplayUser(
|
||||
nav: INav,
|
||||
) {
|
||||
val userState by observeUserInfo(author, accountViewModel)
|
||||
val nickname by observeUserNickname(author, accountViewModel)
|
||||
val petName = nickname?.petName
|
||||
|
||||
UserDisplayNameLayout(
|
||||
picture = {
|
||||
InnerUserPicture(
|
||||
userHex = author.pubkeyHex,
|
||||
userPicture = userState?.info?.picture,
|
||||
userName = userState?.info?.bestName(),
|
||||
userName = petName ?: userState?.info?.bestName(),
|
||||
size = Size20dp,
|
||||
modifier = Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -81,8 +84,8 @@ private fun WatchAndDisplayUser(
|
||||
name = {
|
||||
if (userState != null) {
|
||||
CreateTextWithEmoji(
|
||||
text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
|
||||
tags = userState?.tags ?: EmptyTagList,
|
||||
text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
|
||||
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
||||
maxLines = 1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
+4
-20
@@ -33,6 +33,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
|
||||
@@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
@@ -80,8 +80,6 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -276,7 +274,7 @@ class ChatNewMessageViewModel :
|
||||
)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
|
||||
this.uploadState =
|
||||
ChatFileUploadState(
|
||||
@@ -580,7 +578,7 @@ class ChatNewMessageViewModel :
|
||||
val messageText = message.text.toString()
|
||||
val urls = findURLs(messageText)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
|
||||
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
val message = messageText
|
||||
|
||||
@@ -635,16 +633,6 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
@@ -776,13 +764,9 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
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.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
|
||||
@@ -93,7 +94,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
|
||||
|
||||
+1
-1
@@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.showCount
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
|
||||
|
||||
+4
-19
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
|
||||
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
@@ -88,8 +88,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.notify
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -212,7 +210,7 @@ open class ChannelNewMessageViewModel :
|
||||
)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
|
||||
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
|
||||
}
|
||||
@@ -433,7 +431,7 @@ open class ChannelNewMessageViewModel :
|
||||
|
||||
val urls = findURLs(messageText)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
|
||||
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
|
||||
|
||||
val channelRelays = channel.relays()
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
@@ -597,16 +595,6 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
@@ -690,10 +678,7 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
+1
-1
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
|
||||
+1
-1
@@ -83,6 +83,7 @@ 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.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
@@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
|
||||
|
||||
+4
-18
@@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
@@ -87,8 +87,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -234,7 +232,7 @@ class LongFormPostViewModel :
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
}
|
||||
|
||||
fun load(
|
||||
@@ -416,7 +414,7 @@ class LongFormPostViewModel :
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null
|
||||
|
||||
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
|
||||
val emojis = account.emoji.findEmojiTags(tagger.message)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
@@ -446,16 +444,6 @@ class LongFormPostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
private fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadCoverImage(
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
@@ -704,9 +692,7 @@ class LongFormPostViewModel :
|
||||
}
|
||||
|
||||
fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.reset()
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
@@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
|
||||
|
||||
+4
-20
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
|
||||
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
@@ -79,8 +79,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -212,7 +210,7 @@ open class NewProductViewModel :
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
}
|
||||
|
||||
fun editFromDraft(draft: Note) {
|
||||
@@ -351,7 +349,7 @@ open class NewProductViewModel :
|
||||
)
|
||||
tagger.run()
|
||||
|
||||
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
|
||||
val emojis = account.emoji.findEmojiTags(tagger.message)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() }
|
||||
|
||||
@@ -391,16 +389,6 @@ open class NewProductViewModel :
|
||||
return template
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
@@ -570,13 +558,9 @@ open class NewProductViewModel :
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -76,6 +76,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
|
||||
@@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpPanel
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
|
||||
@@ -142,6 +142,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -327,9 +328,10 @@ private fun NewPostScreenBody(
|
||||
accountViewModel = accountViewModel,
|
||||
label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null,
|
||||
showWhenEmpty = postViewModel.wantsPrivateNote,
|
||||
mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(),
|
||||
onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser },
|
||||
) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
postViewModel.toggleNotify(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +352,7 @@ private fun NewPostScreenBody(
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.wantsPrivateNote && postViewModel.pTags.isNullOrEmpty()) {
|
||||
if (postViewModel.wantsPrivateNote && postViewModel.activeNotifies().isNullOrEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.private_note_no_receivers),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
+62
-41
@@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
@@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
@@ -124,8 +124,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.notify
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -367,10 +365,29 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
// Members of pTags whose bell is off: they keep their chip in the Notify
|
||||
// list (so they are one tap away from being added back) but are dropped
|
||||
// from the outgoing event's p tags.
|
||||
var mutedNotifies by mutableStateOf<Set<HexKey>>(emptySet())
|
||||
|
||||
fun toggleNotify(user: User) {
|
||||
mutedNotifies =
|
||||
if (user.pubkeyHex in mutedNotifies) {
|
||||
mutedNotifies - user.pubkeyHex
|
||||
} else {
|
||||
mutedNotifies + user.pubkeyHex
|
||||
}
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
// The users that will actually be p-tagged: the chip list minus the muted ones.
|
||||
fun activeNotifies(): List<User>? = pTags?.filter { it.pubkeyHex !in mutedNotifies }
|
||||
|
||||
fun addToReplyList(user: User) {
|
||||
if (pTags?.contains(user) != true) {
|
||||
pTags = (pTags ?: emptyList()).plus(user)
|
||||
}
|
||||
mutedNotifies = mutedNotifies - user.pubkeyHex
|
||||
}
|
||||
|
||||
// A single ephemeral signer reused for the whole compose session so that media
|
||||
@@ -517,7 +534,7 @@ open class ShortNotePostViewModel :
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -569,6 +586,7 @@ open class ShortNotePostViewModel :
|
||||
originalNote = replyingTo
|
||||
privateNoteLocked = replyingTo?.isPrivateRumor() == true
|
||||
wantsPrivateNote = privateNoteLocked
|
||||
mutedNotifies = emptySet()
|
||||
replyingTo?.let { replyNote ->
|
||||
if (replyNote.event is BaseThreadedEvent) {
|
||||
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
@@ -577,20 +595,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
if (replyNote.event !is CommunityDefinitionEvent) {
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions =
|
||||
(replyNote.event as? TextNoteEvent)
|
||||
?.mentions()
|
||||
?.toSet()
|
||||
?.map { LocalCache.getOrCreateUser(it.pubKey) }
|
||||
?: emptyList()
|
||||
|
||||
if (currentMentions.contains(replyUser)) {
|
||||
this.pTags = currentMentions
|
||||
} else {
|
||||
this.pTags = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
this.pTags = threadMembers(replyNote, this.eTags)
|
||||
}
|
||||
}
|
||||
?: run {
|
||||
@@ -708,6 +713,22 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
// Everyone taking part in the thread gets a Notify chip: whoever the parent
|
||||
// already p-tags, plus the author of every note in the reply chain. Members
|
||||
// the user doesn't want to ping are muted via their chip's bell, not removed.
|
||||
private fun threadMembers(
|
||||
replyNote: Note,
|
||||
threadNotes: List<Note>?,
|
||||
): List<User> {
|
||||
val mentions =
|
||||
(replyNote.event as? TextNoteEvent)
|
||||
?.mentions()
|
||||
?.map { LocalCache.getOrCreateUser(it.pubKey) }
|
||||
?: emptyList()
|
||||
val authors = threadNotes?.mapNotNull { it.author } ?: emptyList()
|
||||
return (mentions + authors + listOfNotNull(replyNote.author)).distinct()
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draftEvent: TextNoteEvent) {
|
||||
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
|
||||
canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null
|
||||
@@ -786,6 +807,19 @@ open class ShortNotePostViewModel :
|
||||
privateNoteLocked = originalNote?.isPrivateRumor() == true
|
||||
wantsPrivateNote = privateNoteLocked
|
||||
|
||||
// A muted thread member is simply absent from the draft's p tags, so
|
||||
// rebuild the full chip list and mark whoever the draft dropped as muted.
|
||||
val draftNotifies = pTags.orEmpty().map { it.pubkeyHex }.toSet()
|
||||
val members =
|
||||
originalNote
|
||||
?.takeIf { it.event !is CommunityDefinitionEvent }
|
||||
?.let { threadMembers(it, eTags) }
|
||||
.orEmpty()
|
||||
mutedNotifies = members.mapNotNullTo(mutableSetOf()) { member -> member.pubkeyHex.takeIf { it !in draftNotifies } }
|
||||
if (members.isNotEmpty()) {
|
||||
pTags = (pTags.orEmpty() + members).distinct()
|
||||
}
|
||||
|
||||
if (forwardZapTo.value.items.isNotEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
@@ -845,6 +879,7 @@ open class ShortNotePostViewModel :
|
||||
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
LocalCache.checkGetOrCreateUser(it[1])
|
||||
}
|
||||
mutedNotifies = emptySet()
|
||||
|
||||
canUsePoll = originalNote == null
|
||||
canUseZapPoll = originalNote == null
|
||||
@@ -917,6 +952,7 @@ open class ShortNotePostViewModel :
|
||||
draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
LocalCache.checkGetOrCreateUser(it[1])
|
||||
}
|
||||
mutedNotifies = emptySet()
|
||||
|
||||
canUsePoll = originalNote == null
|
||||
canUseZapPoll = originalNote == null
|
||||
@@ -1168,9 +1204,11 @@ open class ShortNotePostViewModel :
|
||||
val tags = prepareETagsAsReplyTo(replyingTo, null)
|
||||
accountViewModel.fixReplyTagHints(tags)
|
||||
markedETags(tags)
|
||||
notify(replyingTo.toPTag())
|
||||
if (replyingTo.event.pubKey !in mutedNotifies) {
|
||||
notify(replyingTo.toPTag())
|
||||
}
|
||||
}
|
||||
pTags?.let { userList ->
|
||||
activeNotifies()?.let { userList ->
|
||||
val tags =
|
||||
userList.map {
|
||||
val tag = it.toPTag()
|
||||
@@ -1190,7 +1228,7 @@ open class ShortNotePostViewModel :
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message.text.toString().trim(),
|
||||
pTags,
|
||||
activeNotifies(),
|
||||
eTags,
|
||||
accountViewModel,
|
||||
)
|
||||
@@ -1201,7 +1239,7 @@ open class ShortNotePostViewModel :
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null
|
||||
|
||||
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
|
||||
val emojis = account.emoji.findEmojiTags(tagger.message)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
@@ -1381,16 +1419,6 @@ open class ShortNotePostViewModel :
|
||||
replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME)
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
@@ -1512,6 +1540,7 @@ open class ShortNotePostViewModel :
|
||||
voiceSelectedServer = null
|
||||
voiceOrchestrator = null
|
||||
pTags = null
|
||||
mutedNotifies = emptySet()
|
||||
|
||||
wantsPoll = false
|
||||
pollOptions = newStateMapPollOptions()
|
||||
@@ -1562,10 +1591,6 @@ open class ShortNotePostViewModel :
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
pTags = pTags?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
open fun addToMessage(it: String) {
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
|
||||
onMessageChanged()
|
||||
@@ -1622,13 +1647,9 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -36,13 +36,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
|
||||
+4
-19
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
|
||||
@@ -50,7 +51,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
@@ -75,8 +75,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -212,7 +210,7 @@ open class NestNewMessageViewModel :
|
||||
)
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
|
||||
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
|
||||
}
|
||||
@@ -427,7 +425,7 @@ open class NestNewMessageViewModel :
|
||||
|
||||
val urls = findURLs(messageText)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
|
||||
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
|
||||
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
|
||||
@@ -467,16 +465,6 @@ open class NestNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
@@ -560,10 +548,7 @@ open class NestNewMessageViewModel :
|
||||
}
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
+43
-27
@@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
@@ -79,6 +80,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -119,33 +121,47 @@ class NotificationFeedFilter(
|
||||
)
|
||||
|
||||
val NOTIFICATION_KINDS =
|
||||
// The core subscription kinds are shared with Desktop through
|
||||
// `commons/.../moderation/notifications/NotificationKinds.SUBSCRIPTION_KINDS`
|
||||
// so a change on either platform automatically propagates.
|
||||
// Android-only extras (badge awards, git issues/patches/PRs,
|
||||
// highlights, polls, videos, voice, public messages,
|
||||
// live-activities chat) stay here because Desktop has no
|
||||
// rendering for those kinds today.
|
||||
com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds
|
||||
.SUBSCRIPTION_KINDS
|
||||
.toSet() +
|
||||
setOf(
|
||||
BadgeAwardEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
VoiceEvent.KIND,
|
||||
VoiceReplyEvent.KIND,
|
||||
) + ADDRESSABLE_KINDS
|
||||
// Kinds that RENDER as a row on the Notifications tab. This is a
|
||||
// display gate over whatever is already in LocalCache — it plays no
|
||||
// part in relay subscriptions (those live in
|
||||
// FilterNotificationsToPubkey, the chat datasources, and the wallet
|
||||
// assembler). It deliberately does NOT share Desktop's
|
||||
// `NotificationKinds.SUBSCRIPTION_KINDS`: that list answers "what to
|
||||
// ask relays for / toast on" and includes envelope kinds like
|
||||
// GiftWrap (1059), whose created_at is randomized up to 2 days back
|
||||
// (NIP-59). Envelopes never render here — the unwrapped inner event
|
||||
// (kind 14/15/…) is the feed row, same rule NotificationDispatcher
|
||||
// applies to push. NotificationKindsContractTest pins the
|
||||
// relationship between the two lists.
|
||||
setOf(
|
||||
BadgeAwardEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
NutzapEvent.KIND,
|
||||
OnchainZapEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
PollEvent.KIND,
|
||||
ZapPollEvent.KIND,
|
||||
PrivateDmEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
VoiceEvent.KIND,
|
||||
VoiceReplyEvent.KIND,
|
||||
) + ADDRESSABLE_KINDS
|
||||
|
||||
// How deep to walk a public chat reply chain looking for one of the
|
||||
// user's own messages. Bounds the cost on very long threads; the
|
||||
|
||||
+1
-1
@@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
@@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
|
||||
|
||||
+4
-20
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
|
||||
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
|
||||
@@ -84,8 +84,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
@@ -222,7 +220,7 @@ class NewPublicMessageViewModel :
|
||||
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
|
||||
|
||||
this.emojiSuggestions?.reset()
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
|
||||
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
|
||||
}
|
||||
|
||||
fun load(users: Set<HexKey>) {
|
||||
@@ -393,7 +391,7 @@ class NewPublicMessageViewModel :
|
||||
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
|
||||
|
||||
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
|
||||
val emojis = account.emoji.findEmojiTags(tagger.message)
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
|
||||
@@ -419,16 +417,6 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun findEmoji(
|
||||
message: String,
|
||||
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
|
||||
): List<EmojiUrlTag> {
|
||||
if (myEmojiSet == null) return emptyList()
|
||||
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
|
||||
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(
|
||||
alt: String?,
|
||||
contentWarningReason: String?,
|
||||
@@ -633,13 +621,9 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.autocompleteInto(message, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -113,6 +113,10 @@ fun DrawAdditionalInfo(
|
||||
val showAppRecommendations by ui.showProfileAppRecommendations.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = SpacedBy3dp) {
|
||||
// the nickname the account gave this user, on top of (not replacing)
|
||||
// the profile's own display name below
|
||||
UserNicknameCard(baseUser, accountViewModel)
|
||||
|
||||
if (displayName != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.profile.header
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.nip85TrustedAssertions.ui.EditNicknameDialog
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
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.placeholderText
|
||||
|
||||
/**
|
||||
* The nickname (petname) and private note the account keeps for [baseUser],
|
||||
* shown above the profile's own display name without replacing it. The lock
|
||||
* marks it as private — both fields live NIP-44 encrypted in the account's
|
||||
* contact card. Tapping the card opens the editor.
|
||||
*/
|
||||
@Composable
|
||||
fun UserNicknameCard(
|
||||
baseUser: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val nickname by observeUserNickname(baseUser, accountViewModel)
|
||||
val card = nickname ?: return
|
||||
|
||||
val isEditDialogOpen = remember { mutableStateOf(false) }
|
||||
|
||||
if (isEditDialogOpen.value) {
|
||||
// keeps the account's selected emoji packs loaded for the : autocomplete
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
EditNicknameDialog(
|
||||
user = baseUser,
|
||||
contactCards = accountViewModel.account.contactCards,
|
||||
onSave = { petName, summary -> accountViewModel.updateContactCardPetName(baseUser, petName, summary) },
|
||||
onDismiss = { isEditDialogOpen.value = false },
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
onClick = { isEditDialogOpen.value = true },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 14.dp, bottom = 3.5.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
card.petName?.let {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = card.tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
}
|
||||
|
||||
if (card.petName != null && card.summary != null) {
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
card.summary?.let {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = card.tags,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Lock,
|
||||
contentDescription = stringRes(R.string.nickname_private),
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -22,16 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui.EditNicknameDialog
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
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.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
|
||||
import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -45,6 +49,19 @@ fun UserProfileDropDownMenu(
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val isNicknameDialogOpen = remember { mutableStateOf(false) }
|
||||
|
||||
if (isNicknameDialogOpen.value) {
|
||||
// keeps the account's selected emoji packs loaded for the : autocomplete
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
EditNicknameDialog(
|
||||
user = user,
|
||||
contactCards = accountViewModel.account.contactCards,
|
||||
onSave = { petName, summary -> accountViewModel.updateContactCardPetName(user, petName, summary) },
|
||||
onDismiss = { isNicknameDialogOpen.value = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (!popupExpanded) return
|
||||
|
||||
M3ActionDialog(
|
||||
@@ -88,6 +105,16 @@ fun UserProfileDropDownMenu(
|
||||
|
||||
// Moderation section (if not self)
|
||||
if (accountViewModel.userProfile() != user) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = MaterialSymbols.Edit,
|
||||
text = stringRes(R.string.edit_nickname),
|
||||
) {
|
||||
isNicknameDialogOpen.value = true
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
M3ActionSection {
|
||||
if (accountViewModel.account.isHidden(user)) {
|
||||
M3ActionRow(
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -2823,6 +2823,7 @@
|
||||
<string name="media_actions_dialog_title">إجراءات الوسائط</string>
|
||||
<string name="playback_actions_dialog_title">التشغيل</string>
|
||||
<string name="video_quality_auto">تلقائي</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">البث إلى جهاز</string>
|
||||
<string name="cast_stop_casting">إيقاف البث</string>
|
||||
|
||||
@@ -2702,6 +2702,7 @@
|
||||
<string name="media_actions_dialog_title">মিডিয়া অ্যাকশন</string>
|
||||
<string name="playback_actions_dialog_title">প্লেব্যাক</string>
|
||||
<string name="video_quality_auto">স্বয়ংক্রিয়</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">ডিভাইসে কাস্ট করুন</string>
|
||||
<string name="cast_stop_casting">কাস্টিং বন্ধ করুন</string>
|
||||
|
||||
@@ -3300,6 +3300,7 @@
|
||||
<string name="media_actions_dialog_title">Akce médií</string>
|
||||
<string name="playback_actions_dialog_title">Přehrávání</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">Odeslat do zařízení</string>
|
||||
<string name="cast_stop_casting">Ukončit odesílání</string>
|
||||
|
||||
@@ -3168,6 +3168,7 @@
|
||||
<string name="media_actions_dialog_title">Medienaktionen</string>
|
||||
<string name="playback_actions_dialog_title">Wiedergabe</string>
|
||||
<string name="video_quality_auto">Auto</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">An Gerät streamen</string>
|
||||
<string name="cast_stop_casting">Streaming beenden</string>
|
||||
|
||||
@@ -2647,6 +2647,7 @@
|
||||
<string name="media_actions_dialog_title">Ενέργειες Πολυμέσων</string>
|
||||
<string name="playback_actions_dialog_title">Αναπαραγωγή</string>
|
||||
<string name="video_quality_auto">Αυτόματο</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">Μετάδοση σε συσκευή</string>
|
||||
<string name="cast_stop_casting">Διακοπή μετάδοσης</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
|
||||
<!-- Kind display names for relay subscription filter chips -->
|
||||
<!-- M3 Action Dialog titles -->
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<!-- HLS multi-resolution video sharing -->
|
||||
<!-- NIP-75 Zap Goals -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user