Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt
This commit is contained in:
Claude
2026-07-15 04:39:47 +00:00
441 changed files with 32857 additions and 1943 deletions
+20
View File
@@ -282,6 +282,26 @@ Do this before considering the task complete.
- The only acceptable inline fully-qualified names are: a genuine name
collision (prefer `import ... as Alias` instead), or where the language
requires it. Comments, KDoc, and string literals are exempt.
- **Prefer the `androidx.core` KTX extension over the raw platform Java call**
when one exists — this is what Android Lint's `UseKtx` flags. Common swaps:
`Bitmap.createBitmap(w, h, cfg)` → `createBitmap(w, h)`,
`Bitmap.createScaledBitmap(src, w, h, f)` → `src.scale(w, h, f)`,
`Uri.parse(s)` → `s.toUri()`, and `prefs.edit()…apply()` → `prefs.edit { }`.
Only adopt the KTX form when it's behaviour-preserving: keep any explicit
argument that differs from the extension's default (a non-`ARGB_8888`
`Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no
equivalent for (e.g. the `createBitmap` pixels/matrix overloads, or a
conditional-`apply()` editor loop) untouched.
- **This "prefer the KTX sugar" rule does NOT extend to collection operators.**
The KTX preference is about platform wrappers (`Bitmap`/`Uri`/`SharedPreferences`),
which compile to the identical call. Collections are the opposite: in hot
event/parse paths Quartz deliberately uses raw JVM arrays (`TagArray =
Array<Array<String>>`) and the inline `fast*` operators (`fastForEach`,
`fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in
`nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
no intermediate list, and no lambda object. Don't "modernize" those into
stdlib collection calls; match the surrounding hot-path style.
### Navigation Shell
- **Desktop**: Sidebar + main content area
+50 -1
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
---
+7 -3
View File
@@ -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)
+27
View File
@@ -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,208 @@
# Concord — Mobile Integration Plan (mirroring NIP-29 Relay Groups)
## Context
The Concord protocol engine is complete in `quartz/…/concord/` (CORD-01…07,
~65 tests) and driven end-to-end by the `amy concord` CLI over a commons
`ConcordActions` layer. This plan covers the **Android app integration**, and it
deliberately **mirrors the just-merged NIP-29 relay-groups feature** — that work
used Soapbox's Armada as a study base and established the exact Amethyst touch
points a group-chat protocol should plug into. Wherever possible we clone the
NIP-29 file structure with Concord equivalents rather than inventing parallels.
Naming: user-facing = **"Concord Channels"** (Amethyst reserves "community" for
NIP-72). Protocol-internal code keeps the spec term `community`.
## The one structural difference from NIP-29
NIP-29 group metadata (kind 39000) is **relay-signed and public**, so groups are
browsable. Concord communities are **end-to-end encrypted**: the only public
artifact is the addressable kind-33301 invite **bundle**, whose content is
token-gated. Consequences for the mirror:
- **Addressing** is by *derived stream pubkey* (`group_key.pk` per plane/epoch),
not `(hostRelay, groupId)`. A Concord channel lives at its plane address and
may be mirrored on several relays (the community's relay set), not pinned to
one host. So `ConcordChannel.relays()` = the community relay set.
- **Discovery** cannot preview E2EE content. The discovery feed surfaces **public
invite links** (kind-33301 bundles + links shared in notes), filtered by
author/hashtag — the entry action is *redeem a link*, not *browse contents*.
This is a genuinely thinner surface than NIP-29; documented, not a bug.
- **Membership = key possession**, verified locally from the folded Control Plane
+ banlist (already implemented), not from relay-signed 39001/39002.
## Per-account persistence & subscription model (Concord is between NIP-17 and NIP-28/29)
Separate **addressing** from **encryption/membership** and Concord's place is clear:
| Concern | NIP-28 | NIP-29 | NIP-17 | **Concord** |
|---|---|---|---|---|
| Find messages by | channel id | `(relay, h)` | `#p = me` | **`authors=[derived plane pk]`** |
| Content | public | public | E2EE to you | **E2EE to a shared key** |
| Decrypt with | — | — | your key | **per-channel derived conv key** |
| Membership | open | relay roster | key possession | **key possession** |
| "My rooms" home | follow list | kind-10009 | chatroom set | **kind-13302 (carries secrets)** |
The decisive point: a Concord wrap's `p` tag is **ephemeral**, so you can never
find messages with `#p = me` (the NIP-17 model). You subscribe **by author = the
derived plane pubkey** (NIP-28/29 addressing), a query only a secret-holder can
form, and decrypt with the shared plane key (NIP-17 E2EE).
**Home base = kind-13302 `ConcordCommunityList`** (built in quartz): NIP-44
self-encrypted, replaceable, relay-synced. Unlike NIP-17 (only secret is your
identity key) or NIP-29 (public group tags), **each entry carries the community
secrets** (`community_root`, salt, epoch, private-channel keys). Same trust model
as NIP-17's recoverable giftwrapped history: a leaked nsec exposes them, nothing
worse. `ConcordChannelListState` wraps 13302 exactly like `RelayGroupListState`
wraps 10009 / `EphemeralChatListState` wraps its list — **same wiring, entries
hold keys.**
**In-memory projection (LocalCache):** `ConcordChannel` keyed by
`(communityId, channelId)`, holding the folded Control-Plane state + decrypted
messages — recomputed from events, never persisted as identity (the NIP-28/29
half).
**Subscription = per-plane author REQ, fanned out from the joined list** — not a
single `#p=me` catch-all. `ConcordMyChannelsFilterAssembler` (mirrors NIP-29's
`RelayGroupMyJoinedGroupsFilterAssembler`) walks `account.concordChannelList`,
derives each community's control-plane + channel-plane addresses, and issues
`{kinds:[1059], authors:[planePk]}` per plane across the community's relays.
**Secrets at rest:** relay copy is self-NIP-44-encrypted (13302); the on-device
mirror can be wrapped with `commons/keystorage`.
## Layering (same as NIP-29)
- `quartz/…/concord/` — protocol (done)
- `commons/…/model/concord/` — `ConcordChannel`, `ConcordChannelListState`,
membership/view-mode enums, discovery constraint (platform-agnostic)
- `amethyst/…/chats/publicChannels/concord/` — screens, feed filters, datasource
subassemblers, navigation
- `commons/…/actions/ConcordActions.kt` — builders/filters/folding (done)
- `cli/…/commands/Concord*Commands.kt` — verbs (done; already matches the
`RelayGroupCommands` route+verb-map pattern)
## Mirror map (NIP-29 file → Concord equivalent)
### commons state
- `model/nip29RelayGroups/RelayGroupChannel.kt` → **`model/concord/ConcordChannel.kt`**
— a `Channel` subclass keyed by a `ConcordChannelId(communityId, channelId)`,
holding the folded `ConcordCommunityState` + this channel's messages StateFlow,
`relays()` = community relay set, `membershipOf()` from the authority resolver,
`placeholderNote()`.
- `RelayGroupListState.kt` → **`model/concord/ConcordChannelListState.kt`** —
backed by the **kind-13302** joined-communities list (already in quartz:
`ConcordCommunityList`). Exposes `liveCommunities: StateFlow<List<Entry>>` and
`liveServers: StateFlow<Set<communityId>>`. `join(community)`/`leave` do
read-modify-write of the 13302 event. Mirrors `EphemeralChatListState`.
- `RelayGroupMembership.kt` → **`ConcordMembership.kt`** (OWNER/ADMIN/MEMBER/BANNED/
NONE) derived from `AuthorityResolver` (rank + banlist).
- `RelayGroupViewMode.kt` → **`ConcordViewMode.kt`** (INLINE/GROUPED).
- `model/nip29RelayGroups/GroupDiscoveryConstraint.kt` → **`ConcordDiscoveryConstraint.kt`**
(AllPublic / ByPeople / ByHashtags) matching against a public invite bundle.
### Account wiring (`amethyst/…/model/Account.kt`)
Add right after the `relayGroupList` lines (~382): a
`ConcordChannelListState(signer, cache, decryptionCache, scope, settings)` field
+ its decryption cache. Action methods next to `joinRelayGroup` (~1472):
`createConcordCommunity`, `joinConcordFromLink`, `postConcordMessage`,
`createConcordInvite`, `banConcordMember`, `follow/unfollow(ConcordChannel)` →
delegate to `ConcordChannelListState`. Writes go through the community relay set.
Add `concordViewMode` to `AccountSettings.kt`.
### LocalCache (`amethyst/…/model/LocalCache.kt`)
Add a `LargeCache<ConcordChannelId, ConcordChannel>` index + `getOrCreateConcordChannel`,
and route inbound kind-1059 wraps on known plane addresses into the fold (decrypt
→ edition/message). Mirrors `getOrCreateRelayGroupChannel`.
### Messages inbox integration (THE key mirror)
- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` + `ChatroomListNewFeedFilter.kt`
— extend the 5-way `feed()` concatenation to **6-way**: add a `concordChannels`
block reading `account.concordChannelList.liveCommunities`, branching on
`concordViewMode` (INLINE = one row per channel via
`LocalCache.getOrCreateConcordChannel(...).newestChatNote() ?: placeholderNote()`;
GROUPED = one synthetic `ConcordServerRoomNote(communityId, newest)` per
community). Update `applyFilter`/`updateListWith` with a
`filterRelevantConcordMessages(...)` keyed by `concordRowKey()`.
- `chats/rooms/dal/RelayGroupServerRoomNote.kt` → **`ConcordServerRoomNote.kt`** —
synthetic event-less Note collapsing a community's channels into one inbox row.
- `chats/rooms/ChatroomHeaderCompose.kt` — add `rendersWithoutEvent` branches for
`ConcordServerRoomNote` and channel placeholders; `ConcordServerRoomCompose` →
`Route.ConcordServer(communityId)`; `ConcordRoomCompose` (chip = community name)
→ `routeFor(channel)`. **This is where the "chip opens the Concord Channel"
requirement lands.**
### Screens (`amethyst/…/chats/publicChannels/concord/`, mirror `relayGroup/`)
- `ConcordServerList.kt` (community rows) · `ConcordChannelListScreen.kt(communityId)`
(a community's channels, from the folded Control Plane) ·
`ConcordChatScreen.kt(communityId, channelId, …)` (top-level route target) ·
`ConcordChannelView.kt` (reuse the NIP-28 `ChannelFeedViewModel`/`ChannelView`
stack via the `ConcordChannel: Channel` subclass) · `ConcordMembersScreen.kt` ·
`ConcordMetadataScreen.kt`/`ViewModel.kt` (create/edit) · `ConcordTopBar.kt`
(name + role badge + Members/Edit/Invite/Ban/Leave menu) · `LoadConcordChannel.kt`.
- Compose composer gated on `membershipOf(me).isMember()`; else a "redeem an
invite to post" notice.
### Discovery feed (GitRepositories-style triad; thinner than NIP-29)
- `concord/dal/ConcordDiscoveryFeedFilter.kt` (`AdditiveFeedFilter<Note>` over
public kind-33301 bundles; "My Communities" branch = the 13302 list) +
`concord/dal/ConcordDiscoveryConstraint.kt` bridge +
`concord/datasource/subassemblies/FilterConcordBundlesBy{Authors,Follows,Hashtag}.kt`.
`ConcordDiscoveryScreen.kt` = `DisappearingScaffold` + `FeedFilterSpinner` +
`RenderFeedContentState` with `ConcordDiscoveryCard` (name + Join button). FAB →
`ConcordBrowse`/redeem-link.
### Navigation (`ui/navigation/routes/Routes.kt` + `AppNavigation.kt`)
`@Serializable` routes: `Concord`(communityId, channelId, +draftId?/inviteToken?),
`ConcordServer`(communityId), `ConcordMembers`, `ConcordCreate`, `ConcordEdit`,
`Concords`(object, bottom-nav → discovery), `ConcordBrowse`. `RouteMaker.routeFor(ConcordChannel)`
+ deep-link: an invite URL/`nostr:`-embedded link → `Route.Concord(..., inviteToken=…)`,
auto-redeeming on open (mirror NIP-29's inviteCode auto-join). Wire through
`BouncingIntentNav.kt`.
### Invite/redeem UI + linkification
- `InviteConcordDialog.kt` (moderator: mint + share link via `ConcordActions.mintInviteLink`)
· `JoinConcordDialog.kt` (paste a link → redeem) · `ui/components/ConcordInviteCard.kt`
(render a link as a preview card; tap → `Route.Concord(inviteToken)`) ·
`ui/components/ClickableConcordInviteLink.kt` (inline linkify shared invite URLs).
### Notifications (your explicit ask)
Route a Concord message notification click to the **channel chat**, not the feed:
in the notification builder + `BouncingIntentNav`, map a Concord message
notification to `Route.Concord(communityId, channelId)`. Mirror how NIP-29
group notifications resolve via `routeFor`.
### Zaps & likes
Because `ConcordChannel` extends `Channel` and messages render through the shared
`ChannelView`, reactions (kind 7) and zaps attach through the existing chat
reaction/zap path — but they must be **wrapped on the channel plane** (kind-7/9735
rumors sealed like messages, bound to channel+epoch), not published in the clear.
Add `ConcordActions.buildReaction`/`buildZapRequest` that wrap on the plane, and
point the shared reaction/zap affordances at them for Concord notes.
## Build order (each a tested, shippable slice)
1. **commons foundation** — `ConcordChannel`, `ConcordChannelListState` (13302),
membership/view-mode enums; unit tests. Wire into `Account.kt` + `AccountSettings`.
2. **LocalCache index** + inbound wrap folding.
3. **Messages inbox** 6-way concat + `ConcordServerRoomNote` + header render/nav
(delivers the chip-opens-channel behavior).
4. **Chat screens** (reuse NIP-28 `ChannelView`) + nav routes + create/invite/join.
5. **Discovery feed** triad (public invite bundles).
6. **Notifications routing + zaps/likes on-plane.**
## Verification
- commons: `:commons:jvmTest` unit tests for `ConcordChannelListState` (13302
round-trip/merge) and `ConcordChannel` folding, mirroring
`RelayGroupListDecryptionTest`/`RelayGroupChannelTest`.
- Android: `:amethyst:installDebug`; create a community, see it in Messages with a
chip, tap → channel opens, send/receive between two emulators, redeem an invite
link deep-link, verify a notification click opens the chat. Cross-check against
`amy concord` (same relay) for wire interop, and against Armada for protocol
interop (`Nip29ArmadaInteropTest` is the precedent).
## Gotchas carried from the NIP-29 study
- Membership has two independent layers (Concord authority vs NIP-43 relay
membership); we only implement Concord authority.
- Cache-as-floor + optimistic local signing for snappy UX.
- E2EE means no server-side moderation and no metadata preview — surface state
from the local fold only.
@@ -0,0 +1,113 @@
# Dual-mode replies: inline + "minichat" threads across all chats
## Goal
Give every Amethyst chat two ways to reply, chosen at send time:
- **Inline reply** — a normal chat message that references its parent and stays in
the main timeline (today's behavior). On the wire this is the chat protocol's
native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28),
kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM).
- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent
message. It is pulled *out* of the main timeline and shown in a separate
**minichat** ("chat within a chat") opened from the parent. This matches Soapbox
Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread).
The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat
message opens as that message's minichat.** So the same treatment automatically
covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 —
wherever a 1111 lands on a chat message.
## Reuse survey (what already exists — do NOT rebuild)
| Need | Reuse |
|---|---|
| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it |
| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` |
| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` |
| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type |
| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent |
| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) |
| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` |
| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) |
Concord already delivers kind-1111 replies through the existing channel-plane
subscription (they're wrapped like every other rumor), so **no new subscription is
needed for Concord** — only the timeline split, the chip, the minichat screen, and
the composer picker.
## Design
### 1. Wire model (settled — matches Armada)
- Inline reply → native chat reply event, native reply tags, stays in timeline.
- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable
thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus
whatever binding the plane requires (Concord: `channel`/`epoch`). One level:
replying inside a minichat roots the new 1111 at the **same** root message
(parent = the message being answered, root = the minichat root), rendered flat —
so minichat messages don't spawn sub-threads. (The wire still permits nesting;
we render flat.)
### 2. Timeline vs minichat split (rendering)
- **Main feed** excludes kind-1111 comments whose root is a chat message — they
live in the minichat, not as flat siblings. Implemented in the shared
`ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root
onto a message already in the feed (keep everything else).
- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount`
restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route.
### 3. Minichat screen
- A thread screen keyed by the **root message id** (+ the channel/room key needed to
re-derive the plane / re-subscribe). Renders the root message pinned at top, then
its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with
its own composer that always sends kind-1111 rooted at this message.
- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed
it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the
plane live.
### 4. Composer mode picker
- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each
`*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM
`ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`).
- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in
thread"). Default = INLINE (least surprise; user opts into pulling it aside).
- Send branch: `MINICHAT` routes to the kind-1111 builder
(`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the
native reply builder.
### 5. Subscriptions
- **Concord**: none new (1111 arrives via the channel plane). Just ensure the
timeline filter and minichat read `rootNote.replies`.
- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone
`RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[<visible
message ids>]}` (and `#E`) off the feed's current message-id set (from
`FeedContentState`). Reuse the same minichat screen/row.
- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the
kind-14s; deferred — needs an encrypted-comment path, more design.
## Phasing
1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer
toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared
`detailRow`; minichat route + screen; Concord send branch. Delivers the complete
dual-mode experience for Concord and builds every shared component.
2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29;
reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to
reconcile with.
3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred.
## Decisions (settled)
- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle.
- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the
same message; no sub-threads.
- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29).
DMs (phase 3) still deferred.
- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the
minichat reads as "a chat within a chat".
## Verification
- quartz/commons unit tests for the reply-mode builders + the timeline-filter split
(a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`).
- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens
minichat); confirm Armada shows our minichat replies as a thread and its threads
open as our minichat; confirm the "N replies" chip count.
@@ -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.
@@ -0,0 +1,83 @@
# CORD-06 Refounding — real member removal for Concord
## Problem
Concord membership is key possession: a banned member (CORD-04 banlist) still
holds the community's `community_root`, so every client just *declines to show*
their posts — they can still decrypt everything. That is a soft removal. CORD-06
adds the hard removal: rotate the key so a removed member's key stops working for
anything sent afterwards.
The quartz crypto for the kind-3303 rekey blob (`ConcordRekey`, `RekeyBlob`)
already existed and was tested, but nothing in the app called it. This wires the
whole path — build, publish, receive, persist, UI — around a **Refounding**
(whole-community rotation), the removal that matters while Amethyst supports only
public channels (a per-channel rekey needs private channels, not built yet).
## What a Refounding does (CORD-06 §3)
1. Ban the removed members on the current Control Plane (so the compacted snapshot
carries the ban).
2. Roll `community_root` to a fresh random 32 bytes at `rootEpoch + 1`. Public
channels + the Control/Guestbook planes all derive from the root, so rolling it
rotates every plane at once.
3. Republish the **compacted** Control Plane under the new root — keep only each
entity's head edition and re-wrap its *original plaintext seal*, so the original
authors' signatures survive re-encryption (a fresh joiner verifies the slim
state exactly as it verified the full chain).
4. Mint per-recipient kind-3303 rekey blobs delivering the new root to every
retained member, sealed + addressed under the **prior** root on the
`base-rekey-pseudonym(prior_root, community_id, new_epoch)` address — which every
current member precomputes, so they receive it live. A removed member gets no
blob and can never derive the new root.
## Layers
- **quartz** `concord/cord06Rekey/`
- `ConcordKeyDerivation`: `baseRekeyAddress` / `channelRekeyAddress` (the rekey
stream addresses), `epochKeyCommitment` (`prevcommit`, CORD-02 §A.5).
- `ConcordRekey`: signer-based `blobForSigner` / `findNewKeyWithSigner` (bunker
accounts open a blob with one `nip44Decrypt`, no raw key).
- `ConcordRefounding`: `compactControlPlane`, `buildBaseRekeyWraps`, `build`
(whole refounding), `findNewRoot` (receive: verify scope/epoch/continuity, find
my blob). `OpenedStreamEvent` now also carries the inner `seal` so compaction
can re-wrap it. Tests in `ConcordRefoundingTest`.
- **commons**
- `ConcordActions`: `guestbookPlane` / `nextBaseRekeyPlane`, `buildGuestbookJoin`
/ `guestbookMembers`, `buildRefounding`, `openBaseRekey`.
- `ConcordCommunitySession`: folds the Guestbook plane into `members`
(the recipient set), buffers inbound base-rekey wraps (`pendingBaseRekeyWraps`),
exposes `controlPlaneWraps` for compaction, and AUTHs to + subscribes the
Guestbook and next-epoch base-rekey planes (`streamKeys`, `subscribeAddresses`).
- `ConcordSessionRegistry.sync`: rebuilds a session when its entry's root/epoch
changed — the session is a pure function of its entry, so adopting a new root is
just a persisted entry swap.
- `ConcordSubscriptionPlanner.auxiliaryPlaneSubs`: REQs the Guestbook + next
base-rekey planes for every joined community.
- **amethyst**
- `Account`: announces a Guestbook JOIN on create/join (`announceConcordGuestbookJoin`)
so members are visible to a future rotator; `refoundConcordCommunity` (owner /
BAN-holder) bans + rolls + publishes + persists; `drainConcordRekeys` (revision
tick) adopts an inbound rotation from an authorized rotator; `adoptConcordRoot`
persists the new root (prior root kept as a `HeldRoot`) and re-seeds the new
epoch's Guestbook, guarded against double-adopt.
- `AccountViewModel.removeConcordMember`; `ConcordMembersScreen` "Remove from
community" action + confirm dialog, gated exactly like Ban.
## Recipient set
The rotator re-keys **Guestbook membership ∪ the privileged roster ∪ self**, minus
the removed and the already-banned. The Guestbook is best-effort/off-consensus, so
a member who joined but whose Guestbook JOIN hasn't propagated to the rotator would
be missed and locked out — the accepted trade for a serverless, key-possession
membership model. Adopting a new root re-announces the Guestbook JOIN at the new
epoch so cascading removals keep a live membership.
## Known limitations / follow-ups
- No explicit "you were removed" detection: a removed member simply stops receiving
new content (their old-epoch keys still read history). CORD-06's "held all n
chunks, none is mine ⇒ removed" self-eviction is not implemented.
- Per-channel rekey (single private channel) is not wired — needs private channels.
- Race convergence (two rotators, same epoch, lexicographically-lowest-key wins) is
not implemented; single-rotator (owner/admin) refounding is the supported path.
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -81,7 +82,7 @@ class ImageUploadTesting {
.build()
private fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(200, 300)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.images
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
@@ -44,7 +45,7 @@ class ThumbnailDiskCacheInstrumentedTest {
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
cache = ThumbnailDiskCache(cacheDir)
sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg")
val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(64, 64)
sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
}
@@ -97,7 +97,7 @@ class EventSyncTest {
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { authTemplate ->
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)
+10
View File
@@ -193,6 +193,16 @@
<data android:host="iris.to" />
</intent-filter>
<!-- Concord community invite links: https://amethyst.social/invite/<naddr>#<fragment> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="amethyst.social" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -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
@@ -29,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
@@ -49,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
@@ -78,14 +82,29 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
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")
@@ -570,13 +681,13 @@ class AppModules(
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Show messages from the Relay and controls their dismissal. Attributes each NOTIFY to the
// account whose AUTH the relay rejected (accountsCache is declared below; the lambda reads it
// lazily at NOTIFY time, long after init).
val notifyCoordinator = NotifyCoordinator(client) { pubkey -> accountsCache.accounts.value[pubkey] }
// Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts.
val relayAuthPermissionStore by lazy {
DataStoreRelayAuthPermissionStore(appContext)
}
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
@@ -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
@@ -618,9 +756,12 @@ class AppModules(
)
// fire-and-forget NIP-13 mining: posts queue here and publish when mined.
// Capped worker pool so a burst of sends never spawns unbounded miners.
// Template jobs checkpoint to disk (restored on login) and every enqueue
// raises the shortService shield so backgrounding doesn't freeze a miner.
// One job mines at a time, racing half the cores over disjoint nonce
// slices — same total CPU budget as the old 2-job pool, but each post
// finishes ~minerThreads× sooner and the other half of the cores stays
// free for the UI. Template jobs checkpoint to disk (restored on login)
// and every enqueue raises the shortService shield so backgrounding
// doesn't freeze a miner.
val powJobStore by lazy {
PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope)
}
@@ -628,10 +769,22 @@ class AppModules(
val powPublishQueue by lazy {
PoWPublishQueue(
scope = applicationIOScope,
maxConcurrent = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 2),
maxConcurrent = 1,
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 {
@@ -652,6 +805,7 @@ class AppModules(
client = client,
rootFilesDir = { appContext.filesDir },
powQueue = { powPublishQueue },
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
)
val sessionManager =
@@ -730,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.
@@ -803,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,
)
@@ -814,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
@@ -882,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 {
@@ -975,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()
@@ -26,6 +26,7 @@ import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
@@ -162,6 +163,7 @@ private object PrefKeys {
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
const val CONCORD_VIEW_MODE = "concord_view_mode"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
@@ -521,6 +523,7 @@ object LocalPreferences {
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
@@ -646,6 +649,7 @@ object LocalPreferences {
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
@@ -859,6 +863,7 @@ object LocalPreferences {
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
concordViewMode = MutableStateFlow(concordViewMode),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
@@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -248,6 +251,7 @@ class AccountSettings(
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupRelayGroupList: SimpleGroupListEvent? = null,
var backupConcordList: ConcordCommunityListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
@@ -276,6 +280,7 @@ class AccountSettings(
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
@@ -283,6 +288,7 @@ class AccountSettings(
val relayAuthTrustMessageStrangers: MutableStateFlow<Boolean> = MutableStateFlow(false),
) : EphemeralChatRepository,
RelayGroupRepository,
ConcordListRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -304,6 +310,13 @@ class AccountSettings(
}
}
fun updateConcordViewMode(mode: ConcordViewMode) {
if (concordViewMode.value != mode) {
concordViewMode.tryEmit(mode)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -1277,6 +1290,19 @@ class AccountSettings(
}
}
override fun concordList() = backupConcordList
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
// The joined list lives entirely in NIP-44-encrypted content (secrets),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newConcordList == null) return
if (backupConcordList?.id != newConcordList.id) {
backupConcordList = newConcordList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
@@ -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(),
)
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -48,6 +49,8 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -355,6 +358,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
val concordChannels = LargeCache<ConcordChannelId, ConcordChannel>()
val paymentTracker = NwcPaymentTracker()
@@ -723,6 +727,62 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
/**
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
* message-like kinds, attaches it to its channel so the shared chat feed and
* the Messages inbox render it (with previews, threading, OTS, reactions/zaps
* reusing the same id-keyed machinery as every other chat). Reactions (kind 7),
* deletes (kind 5), etc. are consumed too — they wire to their target Note by
* `e`-tag through [justConsume] — but are not themselves added as channel rows.
*
* Fed by [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]
* once a wrap decrypts + validates against the folded Control Plane.
*/
fun consumeConcordRumor(
communityId: String,
channelIdHex: String,
rumor: Event,
) {
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
// so the note already carries its ConcordChannel gatherer when it flows through
// the Messages-list incremental filter (which routes rows by that gatherer).
val messageRow =
if (rumor is ChatEvent || rumor is CommentEvent) {
val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
val note = getOrCreateNote(rumor.id)
// Skip attaching a row for a message we already know is deleted (its kind-5 delete
// was processed first). Otherwise every reproject — which re-emits the whole wrap
// buffer — would re-add then re-remove it, churning the feed. justConsume still
// records the (already-known) deletion below; a delete arriving LATER is handled by
// the normal deletion cascade unlinking the note from its gatherers.
if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note)
ch to note
} else {
null
}
// wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature
// check would fail and the event would never load onto its Note — leaving the chat row
// stuck on the "loading / not found" placeholder. Its authenticity is already established
// by the envelope open path (ConcordStreamEnvelope.open verifies the seal signature,
// binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59
// gift-wrapped DM rumor, so we consume it as pre-verified.
justConsume(rumor, null, true)
// justConsume bails without loading the event when the rumor has already been deleted
// (a kind-5 delete referencing it was processed first — easy to hit in Concord because a
// reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to
// verify. We attached the row up front, so an unpopulated note would otherwise linger as a
// permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message)
// is already handled by the normal deletion cascade unlinking the note from its gatherers.
messageRow?.let { (ch, note) ->
if (note.event == null) ch.removeNote(note)
}
}
fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {
if (isValidHex(key)) {
return getOrCreatePublicChatChannel(key)
@@ -3216,10 +3276,28 @@ object LocalCache : ILocalCache, ICacheProvider {
live.removedNote(newNote)
}
/**
* Resource-usage ledger hook: called with (elapsedNanos, valid) for every
* signature verification so the app can account crypto CPU per day.
* Wired by AppModules like [onchainBackend]; null costs nothing.
*/
@Volatile
var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null
fun justVerify(event: Event): Boolean {
checkNotInMainThread()
return if (!event.verify()) {
val meter = verifyMeter
if (meter == null) return justVerifyInner(event)
val start = System.nanoTime()
val valid = justVerifyInner(event)
meter(System.nanoTime() - start, valid)
return valid
}
private fun justVerifyInner(event: Event): Boolean =
if (!event.verify()) {
try {
event.checkSignature()
} catch (e: Exception) {
@@ -3230,7 +3308,6 @@ object LocalCache : ILocalCache, ICacheProvider {
} else {
true
}
}
fun consume(
event: DraftWrapEvent,
@@ -3740,6 +3817,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
// Concord private joined-communities list (kind 13302). Replaceable, self-encrypted;
// ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")),
// so — exactly like the 10009 list above — it must be stored replaceably or the Concord
// hub stays empty even after the event arrives.
is ConcordCommunityListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is GroupMetadataEvent -> {
consume(event, relay, wasVerified)
}
@@ -3847,7 +3932,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 -> {
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -63,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())
@@ -178,7 +181,7 @@ class AccountCacheState(
val signerWithClientTag =
NostrSignerWithClientTag(
inner = signer,
inner = meterSigner(signer),
clientName = CLIENT_TAG_NAME,
disabled = { !accountSettings.syncedSettings.security.addClientTag.value },
)
@@ -227,6 +230,10 @@ class AccountCacheState(
null
}
// Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one
// account never leaks into another (the store used to be a single app-wide file).
val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir)
return Account(
settings = accountSettings,
signer = signerWithClientTag,
@@ -250,6 +257,7 @@ class AccountCacheState(
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
powQueue = powQueue,
relayAuthPermissionStore = relayAuthPermissionStore,
).also { newAccount ->
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
@@ -43,6 +43,15 @@ fun relayAdvertisesNip(
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
/**
* Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc
* resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field
* NIP-29 relays publish so clients can verify their relay-signed group metadata — see
* [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading,
* or the fetch failed), so it never triggers the warning.
*/
fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null
/**
* Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29:
* "these are addressable events signed by the relay keypair directly … as stated by the NIP-11
@@ -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
@@ -32,6 +32,7 @@ import android.os.Messenger
import android.os.RemoteException
import android.os.SystemClock
import android.util.Log
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
@@ -359,7 +360,7 @@ class NappletBrokerService : Service() {
val intent =
Intent(applicationContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate))
data = ("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)).toUri()
}
runCatching { applicationContext.startActivity(intent) }
.onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) }
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -86,7 +86,7 @@ object WebAppNetworkRegistry {
}
/** The host key for [url] (e.g. `vitorpamplona.com`), or the raw string if it has no host. */
fun hostKeyOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
fun hostKeyOf(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
/** Whether the site behind [url] routes through Tor. Defaults to true (Tor) for any site never set. */
fun useTor(url: String): Boolean = modes[hostKeyOf(url)] ?: true
@@ -27,6 +27,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
@@ -64,7 +65,7 @@ object CalendarReminderNotifier {
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = android.net.Uri.parse(deepLink)
data = deepLink.toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val tapPendingIntent =
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
/**
* Device-wide preferences for the calendar reminder worker.
@@ -40,13 +41,13 @@ class CalendarReminderPrefs(
fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
prefs.edit { putBoolean(KEY_ENABLED, enabled) }
}
fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES)
fun setLeadMinutes(minutes: Int) {
prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply()
prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) }
}
companion object {
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.calendar
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
/**
* Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because
@@ -54,7 +55,7 @@ class CalendarReminderStore(
eventId: String,
eventStartSeconds: Long,
) {
prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply()
prefs.edit { putLong(keyFor(eventId), eventStartSeconds) }
}
/**
@@ -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)
@@ -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()
}
@@ -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"
@@ -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 {
@@ -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()
@@ -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)
}
}
@@ -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
@@ -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() {
@@ -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()
@@ -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,
@@ -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 ->
@@ -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) }
@@ -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
}
@@ -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())
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -22,10 +22,21 @@ package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import kotlin.math.roundToLong
/**
* This device's effective mining rate: the [PoWEstimator] benchmark run with
* the same worker count the mining queue uses (half the cores, see
* [PoWPolicy.minerWorkers] and AppModules.powPublishQueue). Every UI estimate
* must use this rate, or it would describe a single-threaded miner that no
* longer exists. Cached after the first call (~250 ms).
*/
suspend fun deviceHashesPerSecond(): Double = PoWEstimator.hashesPerSecond(PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()))
/**
* "45 seconds" / "10 minutes" / "3 hours" — the one human-readable rendering
* of a PoW duration estimate, shared by the settings picker, the composer
@@ -24,16 +24,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */
private fun venueOwnerPubkey(venueId: String): String? = Address.parse(venueId)?.pubKeyHex
@Composable
fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator)
@@ -45,51 +38,17 @@ fun RelayAuthSubscription(
) {
val account = accountViewModel.account
// The per-account NIP-42 policy ledger now lives on Account (account.relayAuthLedger), so this
// only has to register the account itself. The coordinator decides + signs per account.
val state =
remember(accountViewModel) {
ScreenAuthAccount(account)
}
val ledger =
remember(accountViewModel) {
RelayAuthPermissionLedger(
store = Amethyst.instance.relayAuthPermissionStore,
globalPolicy = { account.settings.defaultRelayAuthPolicy.value },
customToggles = {
RelayAuthCustomToggles(
myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value,
readFollows = account.settings.relayAuthTrustReadFollows.value,
messageFollows = account.settings.relayAuthTrustMessageFollows.value,
messageStrangers = account.settings.relayAuthTrustMessageStrangers.value,
)
},
isInMyRelayList = { relayUrl ->
val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false
normalized in account.trustedRelays.flow.value
},
isBlocked = { relayUrl ->
val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false
normalized in account.blockedRelayList.flow.value
},
// Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty
// enough to reveal our identity to a relay that serves them.
isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors },
// A venue (public chat / community / live stream) is trusted if we've joined it, or
// its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow.
isTrustedVenue = { venueId ->
venueId in account.publicChatList.flowSet.value ||
venueId in account.communityList.flowSet.value ||
venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true
},
)
}
DisposableEffect(state, ledger) {
DisposableEffect(state) {
dataSource.subscribe(state)
dataSource.subscribeLedger(ledger)
onDispose {
dataSource.unsubscribe(state)
dataSource.unsubscribeLedger(ledger)
}
}
}
@@ -22,13 +22,22 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ConcurrentHashMap
class ScreenAuthAccount(
val account: Account,
@@ -36,33 +45,26 @@ class ScreenAuthAccount(
@Stable
class AuthCoordinator(
client: INostrClient,
val client: INostrClient,
scope: CoroutineScope,
val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(),
) {
private val authWithAccounts = ListWithUniqueSetCache<ScreenAuthAccount, Account> { it.account }
private val tempAccount by lazy {
NostrSignerSync()
}
@Volatile private var relayLedgers: List<RelayAuthPermissionLedger> = emptyList()
fun subscribeLedger(ledger: RelayAuthPermissionLedger) {
synchronized(this) { relayLedgers = relayLedgers + ledger }
}
fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) {
synchronized(this) { relayLedgers = relayLedgers - ledger }
}
val receiver =
RelayAuthenticator(
client,
scope,
signWithAllLoggedInUsers = { relayUrl, authTemplate ->
// Reconstruct *why* this relay wants auth from what we're doing with it, so each
// account's ledger can apply follow-based trust and (later) explain the prompt.
// Built lazily so the no-ledgers auto-allow path below doesn't pay for it.
signWithAllLoggedInUsers = { relayUrl, authTemplate, interactive ->
// Concord plane traffic is gated behind NIP-42 as the derived *stream key*, not the
// user: a relay serves a plane's kind-1059 wraps only to a connection authenticated
// as that stream key. These AUTHs expose no user identity (ephemeral derived keys)
// and are signed locally, so we always attach them — independent of the per-account
// policy below — or Concord channels/messages never load. No-op for non-Concord relays.
val streamAuths = signConcordStreamAuths(relayUrl, authTemplate)
// Reconstruct *why* this relay wants auth from what the shared client is doing with
// it. Built lazily so accounts that fail the first-party gate below don't pay for it.
val context by
lazy(LazyThreadSafetyMode.NONE) {
RelayAuthContext(
@@ -74,45 +76,124 @@ class AuthCoordinator(
),
)
}
val currentLedgers = relayLedgers
// Ask the user (only in the ASK case) and fold every account's verdict into one
// decision plus an optional per-relay override to remember.
val outcome =
AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) {
promptBus.requestDecision(relayUrl, context.purposes)
}
outcome.remember?.let { decision ->
currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision)
}
val shouldAuth = outcome.shouldAuth
// One socket is shared by every logged-in account, so an AUTH challenge is not tied
// to any single one of them. We answer PER ACCOUNT: an account only reveals its
// identity to a relay it has a first-party reason to be on (its own inbox/outbox
// traffic, or a relay it configured) AND its own ledger verdict allows it. This is
// what stops account B — or a throwaway key — being billed / de-anonymized on a
// relay only account A uses (the inbox.nostr.wine over-AUTH bug): unlike the old
// "any account ALLOWs → sign with everyone (else a random key)" path, a bystander
// account never signs, and there is no random-key fallback.
val signed = mutableListOf<RelayAuthEvent>()
var askChoice: UserAuthChoice? = null
if (shouldAuth) {
// Remember why we granted this relay so the settings screen can explain it.
currentLedgers.firstOrNull()?.recordGrant(context)
authWithAccounts.distinctValues().forEach forEachAccount@{ screen ->
val account = screen.account
if (!account.signer.isWriteable()) return@forEachAccount
if (!isFirstParty(account, relayUrl)) return@forEachAccount
// distinct() returns Set<Account> (the key type U of ListWithUniqueSetCache)
val results =
authWithAccounts.distinct().mapNotNull {
if (it.signer.isWriteable()) {
try {
it.signer.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
null
val approve =
when (account.relayAuthLedger.decide(context)) {
RelayAuthVerdict.ALLOW -> true
RelayAuthVerdict.DENY -> false
RelayAuthVerdict.ASK -> {
// Prompt at most once per challenge; reuse the answer for any other
// account that also reaches ASK on this same relay. But never block the
// derived stream-key AUTH behind that dialog: on a relay that hosts our
// Concord planes we DISMISS the user-auth ASK (skip account auth) so the
// stream AUTHs return immediately instead of waiting on a prompt.
//
// A non-[interactive] pass is an automatic re-auth off an `auth-required:`
// CLOSED (e.g. a Concord channel-plane REQ refused because the connection
// AUTHed before the control plane folded in its channel stream keys). It
// must never raise a fresh dialog: DISMISS the account ASK and let only the
// already-approved identities (ledger-ALLOW accounts + stream keys) re-send.
val choice =
askChoice ?: (
if (streamAuths.isNotEmpty() || !interactive) {
UserAuthChoice.DISMISS
} else {
promptBus.requestDecision(relayUrl, context.purposes)
}
).also { askChoice = it }
when (choice) {
UserAuthChoice.ALLOW_ONCE -> true
UserAuthChoice.ALWAYS_ALLOW -> {
account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW)
true
}
UserAuthChoice.BLOCK -> {
account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY)
false
}
UserAuthChoice.DISMISS -> false
}
} else {
null
}
}
// Always auth, even with random keys
if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate))
} else {
emptyList()
if (approve) {
// Remember why we granted this relay so the settings screen can explain it.
account.relayAuthLedger.recordGrant(context)
try {
signed.add(account.signer.sign(authTemplate))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
}
}
}
signed + streamAuths
},
)
/**
* Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every
* watched account. Signed locally from the derived stream secret (a raw [KeyPair] via
* [NostrSignerSync]) — never the account signer, and never surfacing the user's identity.
*/
private suspend fun signConcordStreamAuths(
relayUrl: NormalizedRelayUrl,
authTemplate: EventTemplate<RelayAuthEvent>,
): List<RelayAuthEvent> {
val secrets = authWithAccounts.distinct().flatMap { it.concordSessions.streamAuthSecretsFor(relayUrl) }
if (secrets.isEmpty()) return emptyList()
return secrets.mapNotNull { secret ->
try {
// Cache the signer by secret so we don't re-derive the secp256k1 keypair for every
// plane on every relay challenge/reconnect.
streamSigners.getOrPut(secret.toHexKey()) { NostrSignerSync(KeyPair(privKey = secret)) }.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e)
null
}
}
}
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
/**
* True when [account] has a first-party reason to authenticate with [relayUrl] on the shared
* client: it is publishing its own event there, a subscription there is reading its own
* inbox/outbox (`#p` or `authors` names its pubkey), or the relay is in its own relay list.
*
* Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party:
* that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared
* auth context carries the OTHER account's counterparties, evaluated against this account's
* follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't
* use are therefore no longer auto-authed — a deliberate privacy-positive trade-off.
*/
private fun isFirstParty(
account: Account,
relayUrl: NormalizedRelayUrl,
): Boolean =
RelayAuthFirstParty.hasReason(
me = account.pubKey,
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
)
fun destroy() {
receiver.destroy()
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/**
* Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides
@@ -45,11 +46,10 @@ class DataStoreRelayAuthPermissionStore(
) : RelayAuthPermissionStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val store: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(
produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") },
)
}
// DataStore v1 throws if two instances are ever active on the same file. loadAccount can build
// this store more than once for the same account (re-login, cache races), so the underlying
// DataStore is shared per absolute file path across the process instead of created per instance.
private val store: DataStore<Preferences> get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb"))
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? {
val raw = store.data.first()[decisionKey(relayUrl)] ?: return null
@@ -198,6 +198,15 @@ class DataStoreRelayAuthPermissionStore(
private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}")
companion object {
// One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once
// per path, so concurrent constructions for the same account share a single active DataStore.
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
stores.computeIfAbsent(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
private const val DECISION_PREFIX = "allow:"
private const val URL_PREFIX = "url:"
private const val RATIONALE_PREFIX = "rat:"
@@ -0,0 +1,68 @@
/*
* 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.relayClient.authCommand.model
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
/**
* Volatile, non-persistent [RelayAuthPermissionStore]. Used as the default for [com.vitorpamplona.amethyst.model.Account]
* instances built without a disk-backed store (Compose previews, unit tests, the mock account view
* models) so nothing on the auth path has to null-check the store.
*/
class InMemoryRelayAuthPermissionStore : RelayAuthPermissionStore {
private val decisions = mutableMapOf<String, RelayAuthDecision>()
private val rationale = mutableMapOf<String, MutableMap<AuthPurposeKind, MutableSet<String>>>()
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = decisions[relayUrl]
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) {
decisions[relayUrl] = decision
}
override suspend fun clearDecision(relayUrl: String) {
decisions.remove(relayUrl)
}
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = decisions.toMap()
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {
val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() }
for ((kind, pubkeys) in additions) {
forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys)
}
}
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap()
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } }
override suspend fun clearRationale(relayUrl: String) {
rationale.remove(relayUrl)
}
}
@@ -54,6 +54,12 @@ class ListWithUniqueSetCache<T, U>(
return newSet
}
/** One representative [T] per unique key — the first occurrence wins. */
fun distinctValues(): List<T> {
val seen = HashSet<U>()
return list.get().filter { seen.add(key(it)) }
}
fun forEachSubscriber(action: (T) -> Unit) {
list.get().forEach(action)
}
@@ -0,0 +1,56 @@
/*
* 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.relayClient.authCommand.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Whether a given account has a *first-party* reason to authenticate (NIP-42) with a relay on the
* shared [com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient]. Pure so the per-account
* signing gate can be tested without a live client, signer, or Compose.
*
* The socket is shared by every logged-in account, so "this relay wants auth" says nothing about
* *which* account should answer. An account should reveal its identity to a relay only when:
* - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to
* the recipient's inbox relay), or
* - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which
* is where its own inbox/outbox reads are routed anyway).
*
* Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is
* NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans
* it out to the union of everyone's relays, so account B's pubkey routinely rides a subscription to
* account A's paid relay. Trusting that dragged bystander accounts (e.g. into inbox.nostr.wine's
* AUTH, and its bill). Genuine own-inbox reads still qualify via [myRelays] — the relay serving them
* is by definition in the account's own list.
*/
object RelayAuthFirstParty {
fun hasReason(
me: HexKey,
relayUrl: NormalizedRelayUrl,
pendingEvents: List<Event>,
myRelays: Set<NormalizedRelayUrl>,
): Boolean {
if (pendingEvents.any { it.pubKey == me }) return true
return relayUrl in myRelays
}
}
@@ -0,0 +1,105 @@
/*
* 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.relayClient.authCommand.model
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* In-memory, warm-cached view over one account's [RelayAuthPermissionStore] (a per-account file —
* see [DataStoreRelayAuthPermissionStore] built from `accounts/<pubkey>/`). Held on the
* [com.vitorpamplona.amethyst.model.Account] like the other state caches.
*
* The ALLOW/DENY overrides are the only thing read on the hot NIP-42 decision path
* ([RelayAuthPermissionLedger.decide]). They are snapshotted into memory once, right after the
* account loads, and served from there — so an incoming AUTH challenge is answered without a disk
* read (a slow disk hit here would stall login-time relay auth). Writes update memory *and* disk.
*
* Rationale + last-used are read only by the settings screen, off the hot path, so they pass
* straight through to disk. Implements [RelayAuthPermissionStore] so it drops into every existing
* call site (the ledger and the settings screen) unchanged.
*/
class RelayAuthPermissionCache(
private val disk: RelayAuthPermissionStore,
scope: CoroutineScope,
) : RelayAuthPermissionStore {
private val loaded = CompletableDeferred<Unit>()
private val _overrides = MutableStateFlow<Map<String, RelayAuthDecision>>(emptyMap())
/** Per-relay overrides for this account, observable so the settings screen refreshes on change. */
val overrides: StateFlow<Map<String, RelayAuthDecision>> = _overrides.asStateFlow()
init {
scope.launch {
_overrides.value = disk.allDecisions()
loaded.complete(Unit)
}
}
/** Non-suspending override lookup — returns null until the initial warm load finishes. */
fun decisionOrNull(relayUrl: String): RelayAuthDecision? = _overrides.value[relayUrl]
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? {
loaded.await()
return _overrides.value[relayUrl]
}
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) {
disk.storeDecision(relayUrl, decision)
_overrides.update { it + (relayUrl to decision) }
}
override suspend fun clearDecision(relayUrl: String) {
disk.clearDecision(relayUrl)
_overrides.update { it - relayUrl }
}
override suspend fun allDecisions(): Map<String, RelayAuthDecision> {
loaded.await()
return _overrides.value
}
// --- rationale + last-used: settings-screen only, never on the auth decision path ---
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) = disk.recordUse(relayUrl, additions)
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = disk.loadRationale(relayUrl)
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = disk.allRationales()
override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl)
override suspend fun allLastUsed(): Map<String, Long> = disk.allLastUsed()
}
@@ -21,22 +21,19 @@
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.flow.map
@Composable
fun DisplayNotifyMessages(
accountViewModel: AccountViewModel,
nav: INav,
) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav)
) = DisplayNotifyMessages(accountViewModel.account.relayNotifications, accountViewModel, nav)
@Composable
fun DisplayNotifyMessages(
@@ -44,17 +41,10 @@ fun DisplayNotifyMessages(
accountViewModel: AccountViewModel,
nav: INav,
) {
val flow =
remember(accountViewModel) {
requests.transientPaymentRequests.map {
it.filter { notifyMsg ->
notifyMsg.relayUrl in accountViewModel.account.dmRelayList.flow.value ||
notifyMsg.relayUrl in accountViewModel.account.nip65RelayList.allFlowNoDefaults.value
}
}
}
val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet())
// [requests] is THIS account's own cache. NotifyCoordinator only files a NOTIFY under the account
// whose AUTH the relay rejected, so there is no cross-account leak to filter out here — a prompt
// in this cache genuinely belongs to this account.
val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle()
openDialogMsg.value.firstOrNull()?.let { request ->
NotifyRequestDialog(
@@ -20,20 +20,98 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
import android.util.LruCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayNotifier
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.toClient.NotifyMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import java.util.concurrent.ConcurrentHashMap
/**
* Routes relay `NOTIFY` payment prompts to the account they actually concern.
*
* A relay's NOTIFY does not reliably name a pubkey, so we can't parse the account out of the
* message. Instead we correlate it with the AUTH that triggered it: a paid relay answers an
* unauthorized AUTH with `OK <authEventId> false …` immediately followed by the NOTIFY. We *signed*
* that auth event, so [AuthCmd.event] tells us which account's key it was. We remember that per auth
* event, resolve the failing `OK` back to the signer, and drop the NOTIFY into THAT account's own
* [NotifyRequestsCache] ([Account.relayNotifications]).
*
* The cache is per account (not a process-wide singleton), so a prompt for account A can never
* surface under account B — and an unattributable NOTIFY is dropped rather than shown to the wrong
* account. This is the fix for the stale-global-cache leak where switching accounts re-surfaced
* another account's inbox.nostr.wine prompt.
*/
class NotifyCoordinator(
client: INostrClient,
private val client: INostrClient,
private val accountForPubkey: (HexKey) -> Account?,
) {
val requests = NotifyRequestsCache()
companion object {
const val TAG = "NotifyCoordinator"
private const val AUTH_EVENT_CACHE = 256
}
val receiver =
RelayNotifier(client) { message, relay ->
requests.addPaymentRequestIfNew(message, relay.url)
// authEventId -> the pubkey we signed it with. Bounded: only a handful of relays re-auth.
private val signerOfAuthEvent = LruCache<HexKey, HexKey>(AUTH_EVENT_CACHE)
// relay -> pubkey of the auth the relay most recently rejected there (the one it will bill).
private val billedPubkeyAt = ConcurrentHashMap<NormalizedRelayUrl, HexKey>()
private val listener =
object : RelayConnectionListener {
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
if (cmd is AuthCmd) {
signerOfAuthEvent.put(cmd.event.id, cmd.event.pubKey)
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
when (msg) {
is OkMessage ->
if (!msg.success) {
signerOfAuthEvent.get(msg.eventId)?.let { billedPubkeyAt[relay.url] = it }
}
is NotifyMessage -> route(relay.url, msg.message)
else -> {}
}
}
}
private fun route(
relay: NormalizedRelayUrl,
message: String,
) {
// Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution.
// An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than
// risk surfacing it under the wrong account.
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey)
account?.relayNotifications?.addPaymentRequestIfNew(message, relay)
}
init {
Log.d(TAG, "Init, Subscribe")
client.addConnectionListener(listener)
}
fun destroy() {
receiver.destroy()
Log.d(TAG, "Destroy, Unsubscribe")
client.removeConnectionListener(listener)
}
}
@@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
@@ -128,6 +130,15 @@ class RelaySubscriptionsCoordinator(
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
// Concord Channels (encrypted communities). One assembler keeps every joined community's
// control + channel planes live (kind-1059 by derived stream address).
val concordChannels = ConcordChannelFilterAssembler(client)
// On-demand backward history pager for whichever Concord Channel screen is open (older wraps by
// until+limit, per relay), the Concord analog of the per-conversation NIP-04 history.
val concordChannelHistory = ConcordChannelHistoryFilterAssembler(client)
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val gitRepository = RepositoryFilterAssembler(client)
@@ -194,6 +205,8 @@ class RelaySubscriptionsCoordinator(
relayGroupThreadFeed,
relayGroupWarmup,
relayGroupsDiscovery,
concordChannels,
concordChannelHistory,
account,
accountForeground,
home,
@@ -20,7 +20,9 @@
*/
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.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -84,6 +86,12 @@ val AccountInfoAndListsFromKeyKinds2 =
// Loaded up-front so "My Groups" and group memberships resolve immediately at login,
// without waiting for the groups screen to mount its own subscription.
SimpleGroupListEvent.KIND,
// Concord private joined-communities list (kind 13302, CORD-05): the self-encrypted
// entries carrying each community's secrets. Loaded up-front for the same reason as
// the NIP-29 list above — so communities joined on another device or client (e.g. the
// Armada reference client, sharing this key) surface in the Concord hub at login,
// instead of only appearing after creating/redeeming an invite in Amethyst itself.
ConcordCommunityListEvent.KIND,
// NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always
// useful to have available — wallet event holds the user's P2PK key
// + mint list, nutzap info tells other clients which mints to lock
@@ -124,6 +132,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 =
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity
@@ -214,6 +215,38 @@ fun observeNoteReplyCount(
return flow.collectAsStateWithLifecycle(note.replies.size)
}
/**
* Count of a chat message's **minichat** replies — its kind-1111 [CommentEvent]
* children only (inline quote-replies are ordinary kind-9/42 messages and are not
* counted here). Drives the "N replies" chip that opens the minichat.
*
* Mounting this registers the message with [EventFinderFilterAssemblerSubscription], which
* batches the visible messages' ids into shared REQs for their replies (kind-1111 among
* them) — so for public chats (NIP-28/NIP-29) the thread replies load, and the chip appears,
* just by rendering the rows. Concord's kind-1111 replies instead arrive over the channel
* plane, so that REQ finds nothing there and is a harmless no-op.
*/
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteMinichatReplyCount(
note: Note,
accountViewModel: AccountViewModel,
): State<Int> {
EventFinderFilterAssemblerSubscription(note, accountViewModel)
val flow =
remember(note) {
note
.flow()
.replies.stateFlow
.sample(200)
.mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent })
}
@Composable
fun observeNoteReactions(
note: Note,
@@ -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
}
}
@@ -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)
}
@@ -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(),
)
}
}
@@ -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(),
)
}
@@ -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)
}
}
@@ -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) {}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -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"
}
}
@@ -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() }
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}
}
@@ -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
}
/**
@@ -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()
}
}
}
}
@@ -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
@@ -27,6 +27,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
@@ -223,6 +224,7 @@ fun uriToRoute(
}
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
val nip19 = Nip19Parser.uriToRoute(uri)?.entity
if (nip19 != null) {
@@ -355,3 +357,15 @@ private fun relayGroupInviteRoute(uri: String): Route? {
val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null
return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code)
}
/**
* A shared Concord invite URL (`…/invite/<naddr>#<fragment>`). Cheap substring gates
* keep the parse off the hot path; the whole URL (fragment included) is carried into
* the route so the redeem flow still has the unlock token.
*/
private fun concordInviteRoute(uri: String): Route? =
if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) {
Route.ConcordInvite(uri)
} else {
null
}
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.service.pow.formatTimeLeft
import com.vitorpamplona.amethyst.service.pow.powKindLabelRes
import com.vitorpamplona.amethyst.ui.stringRes
@@ -197,7 +198,7 @@ private fun MiningContent(
val context = LocalContext.current
val hashRate by
produceState<Double?>(initialValue = null) {
value = PoWEstimator.hashesPerSecond()
value = deviceHashesPerSecond()
}
Column(modifier = Modifier.fillMaxWidth()) {
@@ -0,0 +1,74 @@
/*
* 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.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import kotlinx.coroutines.launch
/**
* Renders a Concord invite link (`…/invite/<naddr>#<fragment>`) inline as a
* tappable link that opens the redeem flow ([Route.ConcordInvite], which fetches +
* unlocks the bundle and joins). Long-press copies the full link. Falls back to
* plain text if the literal can't be parsed (detection should guarantee it does).
*/
@Composable
fun ClickableConcordInviteLink(
linkText: String,
nav: INav,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) }
if (parsed == null) {
Text(text = linkText)
return
}
val clickableModifier =
remember(linkText) {
Modifier.combinedClickable(
onLongClick = { scope.launch { clipboardManager.setText(linkText) } },
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
)
}
Text(
text = linkText,
modifier = clickableModifier,
color = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
@@ -54,17 +54,21 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
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
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -217,6 +221,18 @@ private fun DisplayAddress(
return
}
// A Concord invite bundle (kind 33301) is addressed by a bare naddr, but redeeming it
// needs the 16-byte unlock token that only lives in the full invite link's #fragment —
// a naddr alone can't be joined. Show an informative label instead of the generic
// (and here always-empty) addressable-note card.
if (nip19.kind == ConcordInviteBundleEvent.KIND) {
Text(
text = stringRes(R.string.concord_invite_naddr_label) + (additionalChars ?: ""),
color = MaterialTheme.colorScheme.primary,
)
return
}
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) }
if (noteBase == null) {
@@ -298,14 +314,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,
)
}
@@ -0,0 +1,133 @@
/*
* 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.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The rich card form of a Concord invite link in note content — the analog of
* NIP-29's `RelayGroupCard`. Tapping the card opens the redeem/join flow
* ([Route.ConcordInvite], which keeps the full URL so the fragment token
* survives). It fetches + unlocks the kind-33301 bundle in the background (via
* [com.vitorpamplona.amethyst.model.Account.peekConcordInvite]) to fill in the
* community name; until then it shows a stable placeholder so layout never jumps.
*
* Degrades to [ClickableConcordInviteLink] (a plain link) if the URL doesn't parse.
*/
@Composable
fun ConcordInviteCard(
linkText: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) }
if (parsed == null) {
ClickableConcordInviteLink(linkText, nav)
return
}
// Peek the bundle once per link to reveal the community name (null until it resolves).
val invite by produceState<CommunityInvite?>(initialValue = null, linkText) {
value = accountViewModel.account.peekConcordInvite(linkText)
}
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
// Robohash seed: the community id once known (stable), else the link signer.
val robotSeed = invite?.communityId ?: parsed.linkSignerPubKey
val title = invite?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title)
ElevatedCard(
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.concord_invite_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SymbolIcon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = stringRes(R.string.concord_invite_card_join),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
@@ -105,6 +106,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
@@ -533,6 +535,7 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav)
is ConcordInviteLinkSegment -> ClickableConcordInviteLink(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
@@ -573,6 +576,7 @@ private fun RenderWordWithPreview(
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav)
is ConcordInviteLinkSegment -> ConcordInviteCard(word.segmentText, accountViewModel, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
@@ -1010,15 +1014,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,
)
}
}
@@ -149,7 +149,16 @@ fun RobohashFallbackAsyncImage(
val resources = LocalContext.current.resources
SubcomposeAsyncImage(
model = ProfilePictureUrl(bridgedModel),
// The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only
// NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at
// file://) would fail there. Route only remote http(s) pictures through the thumbnail
// cache; hand local/content URIs to Coil's native fetchers, which load them directly.
model =
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(bridgedModel)
} else {
bridgedModel
},
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -38,6 +38,8 @@ private data class ScrollState(
object ScrollStateKeys {
const val NOTIFICATION_SCREEN = "NotificationsFeed"
const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel"
const val NOTIFICATION_SIDE_PANEL_FOLLOWING = "NotificationsSidePanelFollowing"
const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed"
const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed"
const val VIDEO_SCREEN = "VideoFeed"
@@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -80,10 +83,14 @@ fun DisappearingScaffold(
) {
val state = rememberDisappearingBarState()
// Large screens (rail / permanent drawer) pin the chrome: bars never slide away on
// scroll, and the immersive status-bar hiding stays off.
val canHideBars = allowBarHide && !LocalScreenLayout.current.isLargeScreen
// Hold the latest values in state so the NSC's captured lambda stays fresh across
// recompositions without rebuilding the NSC itself.
val latestIsActive by rememberUpdatedState(isActive)
val latestAllowBarHide by rememberUpdatedState(allowBarHide)
val latestAllowBarHide by rememberUpdatedState(canHideBars)
val latestAccountViewModel by rememberUpdatedState(accountViewModel)
val connection =
@@ -100,24 +107,33 @@ fun DisappearingScaffold(
}
// Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars.
if (allowBarHide) {
if (canHideBars) {
ResetBarsOnResume(state)
ImmersiveStatusBarEffect(state)
}
// If the bars were scrolled away when hiding got disabled (e.g. the window grew to a
// large tier mid-scroll), nothing above can bring them back — the nested-scroll
// connection and the resume reset are gone. Snap them visible here instead of
// leaving the chrome stranded off-screen.
LaunchedEffect(canHideBars, state) {
if (!canHideBars) state.resetToVisible()
}
// When bars are pinned, skip attaching the nested-scroll connection entirely.
// The outer Surface provides the Material container color + onBackground as
// LocalContentColor, matching M3 Scaffold's behaviour (without it, default text
// color falls back to Color.Black and is invisible on the dark theme).
val baseModifier =
if (allowBarHide) {
if (canHideBars) {
Modifier.imePadding().nestedScroll(connection)
} else {
Modifier.imePadding()
}
val rootModifier =
baseModifier
.let { if (topBar == null) it.statusBarsPadding() else it }
// systemBars (not just statusBars) so a desktop window's caption bar is respected too.
.let { if (topBar == null) it.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) else it }
.let { if (bottomBar == null) it.navigationBarsPadding() else it }
Surface(
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.getActivity
/** How the app shell presents its top-level navigation for the current window size. */
enum class NavigationStyle {
/** Compact windows (phones): bottom navigation bar + modal drawer. */
BOTTOM_BAR,
/**
* Medium windows (portrait tablets, unfolded foldables): a left navigation rail
* replaces the bottom bar; the drawer stays modal behind the rail's avatar button.
*/
NAV_RAIL,
/** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */
PERMANENT_DRAWER,
}
/**
* The shell layout decisions for the current window, published once per window size change
* through [LocalScreenLayout] so every screen, bar and panel agrees on the same tier.
*/
@Immutable
data class ScreenLayoutSpec(
val navigationStyle: NavigationStyle,
val showsNotificationPanel: Boolean,
) {
/**
* True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin
* the top/bottom chrome (no disappearing bars on scroll).
*/
val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR
companion object {
val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false)
}
}
val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone }
/**
* Minimum window width for the docked notification panel: the permanent drawer
* ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth])
* only coexist comfortably from a landscape-tablet-sized window up.
*/
private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200
val PermanentDrawerWidth = 300.dp
val NotificationPanelWidth = 360.dp
/**
* Maximum width of a screen's content column inside a wide center pane. Every NavHost
* destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects),
* so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading
* column instead of stretching across the pane. Screens that genuinely need the full pane
* (Messages' two-pane split, the embedded browser surfaces) opt out at registration.
*/
val FeedContentMaxWidth = 600.dp
/**
* Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme
* background so the gutters match the screens' own surfaces; on Compact windows the cap is
* wider than the pane and this is a visual no-op.
*/
@Composable
fun CappedScreenContent(content: @Composable () -> Unit) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.TopCenter,
) {
Box(
Modifier
.widthIn(max = FeedContentMaxWidth)
.fillMaxSize(),
) {
content()
}
}
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun rememberScreenLayoutSpec(): ScreenLayoutSpec {
val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass
val windowWidthDp = LocalConfiguration.current.screenWidthDp
return remember(widthSizeClass, windowWidthDp) {
val style =
when (widthSizeClass) {
WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER
WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL
else -> NavigationStyle.BOTTOM_BAR
}
ScreenLayoutSpec(
navigationStyle = style,
showsNotificationPanel =
style == NavigationStyle.PERMANENT_DRAWER &&
windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP,
)
}
}
@@ -29,8 +29,10 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,12 +44,16 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.content.IntentCompat
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
@@ -55,6 +61,10 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec
import com.vitorpamplona.amethyst.ui.navigation.bottombars.LocalTabReselectCoordinator
import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinator
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
@@ -97,9 +107,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroup
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
@@ -228,6 +246,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettin
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ResourceUsageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen
@@ -277,38 +296,56 @@ fun AppNavigation(
) {
val nav = rememberNav()
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
Box(Modifier.fillMaxSize()) {
BuildNavigation(accountViewModel, nav)
// Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy)
// so its favorite resolves as reliably as a pinned web app's URL — the data the embedded
// preloader below and the full-screen launcher both need. Not API-gated: every device's
// launcher benefits, and it's the only preload step that runs below API 30.
FavoriteAppManifestPreloader(accountViewModel)
// Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by
// holding their surfaces attached. Below the drawer (drawn by the layout above) and below
// dialogs (separate windows). API 30+ only, matching the embedded-surface feature.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
EmbeddedTabLayer(bottomBarItems.favoriteIds())
// Warm every pinned tab at startup so the first tap is instant (content already local).
EmbeddedTabPreloader(accountViewModel)
// Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips
// (an embed WebView's theme is fixed at construction, so it can't follow a live switch).
EmbeddedTabThemeWatcher()
// One layout decision per window size for the whole shell: bottom bar vs rail vs
// permanent drawer, plus the docked notification panel. Every screen, bar and panel
// below reads the same spec through LocalScreenLayout. The provider wraps this whole
// function body so anything added to AppNavigation later is inside it by construction.
val screenLayout = rememberScreenLayoutSpec()
val tabReselectCoordinator = remember { TabReselectCoordinator() }
// Mirror the tier for the nav-transition specs, which run outside composition and so
// can't read LocalScreenLayout (see NavTransitionTier).
SideEffect { NavTransitionTier.isLargeScreen = screenLayout.isLargeScreen }
CompositionLocalProvider(
LocalScreenLayout provides screenLayout,
LocalTabReselectCoordinator provides tabReselectCoordinator,
) {
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
Box(Modifier.fillMaxSize()) {
BuildNavigation(accountViewModel, nav)
// Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy)
// so its favorite resolves as reliably as a pinned web app's URL — the data the embedded
// preloader below and the full-screen launcher both need. Not API-gated: every device's
// launcher benefits, and it's the only preload step that runs below API 30.
FavoriteAppManifestPreloader(accountViewModel)
// Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by
// holding their surfaces attached. Below the drawer (drawn by the layout above) and below
// dialogs (separate windows). API 30+ only, matching the embedded-surface feature.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
EmbeddedTabLayer(bottomBarItems.favoriteIds())
// Warm every pinned tab at startup so the first tap is instant (content already local).
EmbeddedTabPreloader(accountViewModel)
// Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips
// (an embed WebView's theme is fixed at construction, so it can't follow a live switch).
EmbeddedTabThemeWatcher()
}
}
}
TrackScreenTime(nav)
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
DisplayNotifyMessages(accountViewModel, nav)
DisplayCrashMessages(accountViewModel, nav)
DisplayResourceUsageAlert(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
ObserveIncomingCalls(accountViewModel)
}
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav)
DisplayNotifyMessages(accountViewModel, nav)
DisplayCrashMessages(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
ObserveIncomingCalls(accountViewModel)
}
@Composable
@@ -324,6 +361,27 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
}
}
/**
* Feeds the resource-usage ledger with time-per-screen. Only the route's
* base name crosses this boundary — [ScreenTimeIntegrator.screenNameOf]
* strips every navigation argument first, so the ledger can say "Profile"
* but never which profile.
*/
@Composable
private fun TrackScreenTime(nav: Nav) {
DisposableEffect(nav.controller) {
val listener =
NavController.OnDestinationChangedListener { _, destination, _ ->
Amethyst.instance.screenTime.onScreen(ScreenTimeIntegrator.screenNameOf(destination.route))
}
nav.controller.addOnDestinationChangedListener(listener)
onDispose {
nav.controller.removeOnDestinationChangedListener(listener)
Amethyst.instance.screenTime.onScreen(null)
}
}
}
@Composable
fun BuildNavigation(
accountViewModel: AccountViewModel,
@@ -335,9 +393,9 @@ fun BuildNavigation(
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) },
) {
composable<Route.Home> { HomeScreen(accountViewModel, nav) }
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
@@ -354,10 +412,10 @@ fun BuildNavigation(
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser> { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser>(capWidth = false) { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WebApp> { WebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.NostrApp> { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEndArgs<Route.WebApp>(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
@@ -396,7 +454,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableCapped<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletSend> { WalletSendScreen(it.walletId, accountViewModel, nav) }
@@ -449,7 +507,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.TopUpMint> { TopUpMintScreen(it.mintUrl, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
composableCapped<Route.Search> { SearchScreen(accountViewModel, nav) }
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
@@ -481,6 +539,7 @@ fun BuildNavigation(
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.NotificationSettings> { NotificationSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ResourceUsage> { ResourceUsageScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav)
@@ -586,6 +645,61 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.Concord> {
ConcordChannelScreen(
communityId = it.communityId,
channelId = it.channelId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ChatMinichat> {
MinichatScreen(
rootId = it.rootId,
concordCommunityId = it.concordCommunityId,
concordChannelId = it.concordChannelId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordServer> {
ConcordChannelListScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordMembers> {
ConcordMembersScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordEdit> {
ConcordEditScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordInvite> {
ConcordInviteScreen(
link = it.link,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEnd<Route.Concords> { ConcordHomeScreen(accountViewModel, nav) }
composableFromEnd<Route.ConcordCreate> { ConcordCreateScreen(accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupMembers> {
RelayGroupMembersScreen(
id = it.id,
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
@@ -33,6 +35,7 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent
// Per-entry hint stamped by Nav.navBottomBar marking that the entry was
// reached via a bottom-nav tab. Used in two places:
@@ -44,41 +47,99 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot"
fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) == true
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
/**
* The shell's current layout tier, mirrored for the transition specs below. Transition
* lambdas run when a navigation starts — outside composition — so they can't read
* LocalScreenLayout; AppNavigation mirrors the spec here instead.
*
* One navigation grammar, tier-scaled motion: phones keep full-width slides (a pushed
* screen physically stacks on top), while large screens use short shared-axis moves —
* content there swaps inside a persistent shell, and a full-pane slide from the right
* reads as disconnected when the click came from the docked drawer on the left.
*/
object NavTransitionTier {
@Volatile
var isLargeScreen: Boolean = false
}
/**
* Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless
* it opted out with `capWidth = false`. Every builder below routes through this, so all
* destinations — top bars included — share the centered column on large screens by default.
*/
@Composable
fun MaybeCappedScreen(
capWidth: Boolean,
content: @Composable () -> Unit,
) {
if (capWidth) {
CappedScreenContent(content)
} else {
content()
}
}
/** Stock fade-transition destination, capped to the reading-column width on wide panes. */
inline fun <reified T : Any> NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
composable<T> { entry ->
CappedScreenContent { content(entry) }
}
}
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable<T>(
enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd },
exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut },
popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn },
popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd },
content = content,
enterTransition = { if (targetState.isBottomNavRoot()) null else enterFromEnd() },
exitTransition = { if (targetState.isBottomNavRoot()) null else exitBehind() },
popEnterTransition = { if (initialState.isBottomNavRoot()) null else popEnterFromBehind() },
popExitTransition = { if (initialState.isBottomNavRoot()) null else popExitToEnd() },
content = { entry ->
MaybeCappedScreen(capWidth) { content(entry) }
},
)
}
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composableFromEnd<T> {
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
) {
composableFromEnd<T>(capWidth) {
content(it.toRoute<T>())
}
}
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable<T>(
enterTransition = { slideInVerticallyFromBottom },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutVerticallyToBottom },
content = content,
enterTransition = { enterFromBottom() },
exitTransition = { exitBehind() },
popEnterTransition = { popEnterFromBehind() },
popExitTransition = { popExitToBottom() },
content = { entry ->
MaybeCappedScreen(capWidth) { content(entry) }
},
)
}
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composableFromBottom<T> {
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
) {
composableFromBottom<T>(capWidth) {
content(it.toRoute())
}
}
inline fun <reified T : Any> NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composable<T> {
content(it.toRoute())
inline fun <reified T : Any> NavGraphBuilder.composableArgs(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
) {
composable<T> { entry ->
MaybeCappedScreen(capWidth) { content(entry.toRoute()) }
}
}
@@ -90,3 +151,29 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta
val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f)
val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f)
/** Fraction of the pane a shared-axis move travels on large screens — a nudge, not a fly-in. */
private const val SHARED_AXIS_FRACTION = 10
val sharedAxisEnterFromEnd = slideInHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween())
val sharedAxisExitToEnd = slideOutHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween())
val sharedAxisEnterFromBottom = slideInVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween())
val sharedAxisExitToBottom = slideOutVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween())
// The outgoing/incoming screen *behind* a push: on phones the pushed screen covers it, so a
// slight scale is enough; on large screens the incoming screen fades, so the one behind must
// fade too or both stay visible mid-transition.
val fadeScaleOut = scaleOut + fadeOut(animationSpec = tween())
val fadeScaleIn = scaleIn + fadeIn(animationSpec = tween())
fun enterFromEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromEnd else slideInHorizontallyFromEnd
fun popExitToEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToEnd else slideOutHorizontallyToEnd
fun enterFromBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromBottom else slideInVerticallyFromBottom
fun popExitToBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToBottom else slideOutVerticallyToBottom
fun exitBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleOut else scaleOut
fun popEnterFromBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleIn else scaleIn
@@ -36,8 +36,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -49,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -73,6 +76,22 @@ fun AppBottomBar(
accountViewModel: AccountViewModel,
onClick: (Route) -> Unit,
) {
// Publish this screen's re-tap behavior even when the bar renders nothing: on large
// screens the navigation rail routes reselect taps back through the coordinator so the
// same per-screen scroll-to-top/refresh logic runs.
val coordinator = LocalTabReselectCoordinator.current
val latestRoute by rememberUpdatedState(selectedRoute)
val latestOnClick by rememberUpdatedState(onClick)
DisposableEffect(coordinator) {
val handler: (Route) -> Unit = { latestOnClick(it) }
coordinator.register({ latestRoute }, handler)
onDispose { coordinator.unregister(handler) }
}
// Large screens replace the bottom bar with the navigation rail (Medium) or the
// permanently docked drawer (Expanded).
if (LocalScreenLayout.current.isLargeScreen) return
// Hide the bar on entries that aren't a tab root (drawer or in-app
// pushes). Mirrors the back-arrow rule in canPop().
if (nav.canPop()) return
@@ -101,6 +120,43 @@ fun AppBottomBar(
}
}
/**
* Resolves the icon model for a pinned favorite: a web favorite's captured favicon (else the
* generic globe), an nsite/napplet's verified manifest icon bundled in its own content (the
* iframe sandbox rules out live capture; else the grid glyph). Shared by the bottom bar and
* the navigation rail.
*/
@Composable
internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
when (fav) {
is FavoriteApp.WebApp -> {
// Captured favicons, keyed so the icon appears once the site's capture lands.
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
remember(fav, iconKeys) {
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
}
}
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
}
/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */
@Composable
internal fun FavoriteEntryIcon(
fav: FavoriteApp,
selected: Boolean,
iconModel: Any?,
) {
Box(Size27Modifier, contentAlignment = Alignment.Center) {
FavoriteAppIcon(
app = fav,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
modifier = Size25Modifier,
iconModel = iconModel,
)
}
}
@Composable
private fun RenderBottomMenu(
items: List<BottomBarEntry>,
@@ -112,9 +168,6 @@ private fun RenderBottomMenu(
// Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan.
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
// Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe.
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
@@ -147,17 +200,7 @@ private fun RenderBottomMenu(
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
// A web favorite uses its captured favicon; an nsite/napplet uses the verified
// icon blob bundled in its own content (the iframe sandbox rules out live capture).
val iconModel =
when (fav) {
is FavoriteApp.WebApp ->
remember(fav, iconKeys) {
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
}
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
}
FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav)
FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav)
}
}
}
@@ -175,18 +218,7 @@ private fun RowScope.FavoriteNavItem(
) {
NavigationBarItem(
alwaysShowLabel = false,
icon = {
Box(Size27Modifier, contentAlignment = Alignment.Center) {
// A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else
// the grid glyph).
FavoriteAppIcon(
app = fav,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
modifier = Size25Modifier,
iconModel = iconModel,
)
}
},
icon = { FavoriteEntryIcon(fav, selected, iconModel) },
// No label — favorite tabs match the built-in items, which show icon only.
selected = selected,
onClick = { nav(destination) },
@@ -216,8 +248,9 @@ private fun RowScope.HasNewItemsIcon(
)
}
/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */
@Composable
private fun NotifiableIcon(
internal fun NotifiableIcon(
selected: Boolean,
def: NavBarItemDef,
destination: Route,
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Medium-width windows: a left rail that carries the same user-configured destinations as the
* phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the
* user's customization and new-item dots carry over. The header avatar opens the modal drawer,
* mirroring the avatar button in the phone top bars. Re-tapping the selected item routes
* through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler.
*/
@Composable
fun AppNavigationRail(
nav: Nav,
accountViewModel: AccountViewModel,
) {
val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
val reselectCoordinator = LocalTabReselectCoordinator.current
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
NavigationRail(
containerColor = MaterialTheme.colorScheme.background,
header = {
// Same affordance as the phone top bars: the account avatar opens the drawer.
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
},
) {
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items.forEach { entry ->
when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item] ?: return@forEach
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
val selected = currentDestination?.hasRoute(destination::class) == true
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(destination)
} else {
nav.navBottomBar(destination)
}
},
icon = { NotifiableIcon(selected, def, destination, accountViewModel) },
)
}
is BottomBarEntry.Favorite -> {
val fav = favoritesById[entry.favoriteId] ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
// Favorites carry arguments (url / coordinate), so class matching alone
// would light up every pinned app of the same kind; compare the full route.
val selected =
remember(navBackStackEntry, destination) {
when (destination) {
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination
else -> false
}
}
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(destination)
} else {
nav.navBottomBar(destination)
}
},
icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) },
)
}
}
}
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
/**
@@ -39,7 +40,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
val FABPaddingFromBottom = 30.dp
@Composable
fun Modifier.fabBottomBarPadding(nav: INav): Modifier = if (nav.canPop()) padding(bottom = FABPaddingFromBottom) else this
fun Modifier.fabBottomBarPadding(nav: INav): Modifier =
if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) {
// canPop entries hide the bar on phones; large screens never render it at all.
padding(bottom = FABPaddingFromBottom)
} else {
this
}
/**
* Convenience wrapper that places [content] in a [Box] with [fabBottomBarPadding] applied.
@@ -67,6 +67,7 @@ enum class NavBarItem {
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
CONCORD,
FOLLOW_PACKS,
LIVE_STREAMS,
NESTS,
@@ -326,6 +327,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.CONCORD to
NavBarItemDef(
id = NavBarItem.CONCORD,
labelRes = R.string.concord_home_title,
icon = MaterialSymbols.Group,
resolveRoute = { Route.Concords },
),
NavBarItem.FOLLOW_PACKS to
NavBarItemDef(
id = NavBarItem.FOLLOW_PACKS,
@@ -448,6 +456,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
/**
* Bridges the shell-level [AppNavigationRail] to the per-screen re-tap behaviors that live in
* each screen's [AppBottomBar] onClick lambda (scroll-to-top, feed refresh, ...).
*
* On phones the bottom bar invokes the screen's lambda directly. On large screens the bar
* renders nothing, but it still registers the screen's lambda here; when the user taps the
* rail item that is already selected, the rail routes the tap back through [reselect] so the
* exact same per-screen logic runs.
*/
@Stable
class TabReselectCoordinator {
private var currentRoute: (() -> Route?)? = null
private var currentHandler: ((Route) -> Unit)? = null
fun register(
route: () -> Route?,
handler: (Route) -> Unit,
) {
currentRoute = route
currentHandler = handler
}
/** Unregisters only if [handler] is still the active one, so a newly composed screen's
* registration is not torn down by the outgoing screen's dispose during a transition. */
fun unregister(handler: (Route) -> Unit) {
if (currentHandler === handler) {
currentHandler = null
currentRoute = null
}
}
/** Invokes the active tab root's handler if it owns [route]. Returns true if handled. */
fun reselect(route: Route): Boolean {
val handler = currentHandler ?: return false
if (currentRoute?.invoke() != route) return false
handler(route)
return true
}
}
val LocalTabReselectCoordinator = staticCompositionLocalOf { TabReselectCoordinator() }
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -56,6 +57,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
@@ -147,59 +150,93 @@ fun DrawerContent(
openSheet: () -> Unit,
accountViewModel: AccountViewModel,
) {
val onClickUser = {
nav.nav(routeFor(accountViewModel.userProfile()))
nav.closeDrawer()
}
ModalDrawerSheet(
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start),
drawerContainerColor = MaterialTheme.colorScheme.background,
drawerTonalElevation = 0.dp,
) {
DrawerContentBody(nav, openSheet, accountViewModel)
}
}
/**
* Expanded windows: the same drawer content, permanently docked on the left of the shell
* instead of sliding in as a modal sheet.
*/
@Composable
fun PermanentDrawerContent(
nav: INav,
openSheet: () -> Unit,
accountViewModel: AccountViewModel,
) {
Surface(
modifier = Modifier.width(PermanentDrawerWidth).fillMaxHeight(),
color = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
) {
Column(
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
Modifier.windowInsetsPadding(
WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start),
),
) {
ProfileContent(
baseAccountUser = accountViewModel.account.userProfile(),
modifier = profileContentHeaderModifier,
accountViewModel,
onClickUser,
)
Column(drawerSpacing) {
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav)
}
FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser)
HorizontalDivider(
thickness = DividerThickness,
modifier = Modifier.padding(top = 20.dp),
)
Spacer(modifier = StdHorzSpacer)
ListContent(
modifier = Modifier.fillMaxWidth(),
openSheet,
accountViewModel,
nav,
)
Spacer(modifier = Modifier.weight(1f))
BottomContent(
accountViewModel.account.userProfile(),
accountViewModel,
nav,
)
DrawerContentBody(nav, openSheet, accountViewModel)
}
}
}
@Composable
private fun DrawerContentBody(
nav: INav,
openSheet: () -> Unit,
accountViewModel: AccountViewModel,
) {
val onClickUser = {
nav.nav(routeFor(accountViewModel.userProfile()))
nav.closeDrawer()
}
Column(
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
ProfileContent(
baseAccountUser = accountViewModel.account.userProfile(),
modifier = profileContentHeaderModifier,
accountViewModel,
onClickUser,
)
Column(drawerSpacing) {
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav)
}
FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser)
HorizontalDivider(
thickness = DividerThickness,
modifier = Modifier.padding(top = 20.dp),
)
Spacer(modifier = StdHorzSpacer)
ListContent(
modifier = Modifier.fillMaxWidth(),
openSheet,
accountViewModel,
nav,
)
Spacer(modifier = Modifier.weight(1f))
BottomContent(
accountViewModel.account.userProfile(),
accountViewModel,
nav,
)
}
}
@Composable
fun ProfileContent(
baseAccountUser: User,
@@ -237,14 +274,14 @@ fun ProfileContentTemplate(
AsyncImage(
model = profileBanner,
contentDescription = stringRes(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
contentScale = ContentScale.Crop,
modifier = bannerModifier,
)
} else {
AsyncImage(
model = R.drawable.profile_banner,
contentDescription = stringResource(R.string.profile_banner),
contentScale = ContentScale.FillWidth,
contentScale = ContentScale.Crop,
modifier = bannerModifier,
)
}
@@ -392,8 +429,10 @@ fun StatusEditBar(
val currentStatus = remember { mutableStateOf(savedStatus ?: "") }
// In the docked drawer the DrawerState never opens (it stays Closed while the drawer
// is always on screen), so the modal close-cancels-editing behavior must not apply there.
LaunchedEffect(nav.drawerState.isClosed) {
if (nav.drawerState.isClosed) {
if (!nav.isDrawerDocked && nav.drawerState.isClosed) {
focusManager.clearFocus(true)
onDone()
} else {
@@ -427,6 +466,10 @@ fun StatusEditBar(
}
focusManager.clearFocus(true)
// Collapse back to the read-only bar: in the docked drawer no
// drawer-close will ever do it, and in the modal drawer this beats
// staying in edit mode until the drawer closes.
onDone()
},
),
singleLine = true,
@@ -442,12 +485,14 @@ fun StatusEditBar(
accountViewModel.updateStatus(address, currentStatus.value)
}
focusManager.clearFocus(true)
onDone()
}
} else {
if (address != null) {
UserStatusDeleteButton {
accountViewModel.deleteStatus(address)
focusManager.clearFocus(true)
onDone()
}
}
}

Some files were not shown because too many files have changed in this diff Show More