mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/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:
@@ -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
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -101,6 +101,25 @@ jobs:
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
|
||||
# (runtime-version is greped from the manifest so this never drifts).
|
||||
# Retried: the runtime download from Flathub is ~1 GB and flatpak
|
||||
# install resumes cleanly on re-run.
|
||||
- name: Install Flatpak tooling + runtimes (linux-portable only)
|
||||
if: matrix.family == 'linux-portable'
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
with:
|
||||
max_attempts: 3
|
||||
timeout_minutes: 15
|
||||
command: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update && sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
FDO_VER=$(grep -E "^runtime-version:" desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml | cut -d"'" -f2)
|
||||
flatpak install --user --noninteractive flathub \
|
||||
"org.freedesktop.Platform//${FDO_VER}" \
|
||||
"org.freedesktop.Sdk//${FDO_VER}"
|
||||
|
||||
# macOS only: import the Developer ID Application cert into a throwaway
|
||||
# keychain so jpackage's codesign pass can find it. Soft — if the
|
||||
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
|
||||
@@ -161,6 +180,36 @@ jobs:
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
|
||||
fi
|
||||
|
||||
# Flatpak bundle: wraps the same createReleaseDistributable tree the
|
||||
# AppImage uses. The manifest (desktopApp/packaging/flatpak/) copies the
|
||||
# prebuilt jpackage tree into /app — no Gradle runs inside the sandbox.
|
||||
# build-bundle emits a single-file .flatpak whose baked-in runtime-repo
|
||||
# lets the user's flatpak fetch the freedesktop runtime from Flathub on
|
||||
# install. --disable-rofiles-fuse: GH runners lack a usable rofiles-fuse.
|
||||
- name: Build Flatpak bundle (linux-portable only)
|
||||
if: matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
PKG="desktopApp/packaging/flatpak"
|
||||
APP_ID="com.vitorpamplona.amethyst.Desktop"
|
||||
OUT="desktopApp/build/flatpak"
|
||||
# Inject the AppStream <release> entry for this build (the checked-in
|
||||
# metainfo deliberately carries none — CI is the source of truth).
|
||||
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
|
||||
"${PKG}/${APP_ID}.metainfo.xml"
|
||||
mkdir -p "$OUT"
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--state-dir="${OUT}/.flatpak-builder" \
|
||||
--repo="${OUT}/repo" \
|
||||
"${OUT}/build-dir" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
flatpak build-bundle "${OUT}/repo" \
|
||||
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
|
||||
"$APP_ID" \
|
||||
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
ls -la "$OUT"
|
||||
|
||||
- name: Collect + rename assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -180,6 +180,11 @@ desktopApp/src/jvmMain/appResources/*/ffmpeg/*
|
||||
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
desktopApp/packaging/appimage/squashfs-root/
|
||||
|
||||
# flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/)
|
||||
.flatpak-builder/
|
||||
desktopApp/packaging/flatpak/**/build-dir/
|
||||
desktopApp/packaging/flatpak/**/repo/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
|
||||
+14
-2
@@ -37,7 +37,9 @@ Platform-specific:
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
[`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md))
|
||||
|
||||
Install Linux RPM tooling:
|
||||
|
||||
@@ -109,6 +111,7 @@ are **not** required to build Amethyst from the committed sources.
|
||||
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
|
||||
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
|
||||
| Windows `.zip` portable | See below (inline `7z`) | — |
|
||||
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
|
||||
|
||||
@@ -148,7 +151,7 @@ Where:
|
||||
| `<version>` | Tag stripped of leading `vX.YY.ZZ` |
|
||||
| `<family>` | `macos`, `windows`, `linux` |
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` |
|
||||
|
||||
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
|
||||
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
|
||||
@@ -160,6 +163,7 @@ Examples:
|
||||
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.12.1-windows-x64.msi`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.flatpak`
|
||||
|
||||
---
|
||||
|
||||
@@ -625,12 +629,18 @@ State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb,
|
||||
expose downgrade migration risks — **prefer a single install channel per
|
||||
machine**.
|
||||
|
||||
**Exception: Flatpak.** The sandbox redirects XDG dirs into
|
||||
`~/.var/app/com.vitorpamplona.amethyst.Desktop/`, so a Flatpak install keeps
|
||||
its own separate state and does not see (or risk downgrading) state written
|
||||
by any other channel.
|
||||
|
||||
| OS | App location | State directories |
|
||||
|---|---|---|
|
||||
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
|
||||
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
|
||||
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
|
||||
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
|
||||
| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` |
|
||||
|
||||
Uninstall:
|
||||
|
||||
@@ -639,6 +649,8 @@ Uninstall:
|
||||
- .deb: `sudo apt remove amethyst`
|
||||
- .rpm: `sudo dnf remove amethyst`
|
||||
- AppImage / tar.gz: delete the file / extracted directory
|
||||
- Flatpak: `flatpak uninstall com.vitorpamplona.amethyst.Desktop` (add
|
||||
`--delete-data` to also remove `~/.var/app/…`)
|
||||
- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually
|
||||
|
||||
---
|
||||
|
||||
@@ -158,13 +158,17 @@ Google Play Services infrastructure.
|
||||
|
||||
### Layer 4: AlarmManager Watchdog (5 minutes)
|
||||
|
||||
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5
|
||||
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5
|
||||
minutes. The receiver checks if the service should be running and restarts it.
|
||||
|
||||
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
|
||||
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
|
||||
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
|
||||
wake the device from sleep, ensuring the check happens even in Doze.
|
||||
delivered), the watchdog will catch it within 5 minutes of the device being awake.
|
||||
The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of
|
||||
sleep every 5 minutes is a battery cost with no payoff, because a service restarted
|
||||
on a sleeping device can't do useful network work until the device wakes anyway.
|
||||
While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover
|
||||
delivery; the moment the device wakes, the pending watchdog alarm fires.
|
||||
|
||||
### Layer 5: WorkManager Periodic Catch-Up (15 minutes)
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import org.gradle.api.services.BuildService
|
||||
import org.gradle.api.services.BuildServiceParameters
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -342,6 +345,30 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// Gradle schedules Kotlin compilations of different variants of this module
|
||||
// concurrently (e.g. playDebug + playBenchmark when CI runs unit tests, lint,
|
||||
// and assembleBenchmark in one invocation), but they all share a single Kotlin
|
||||
// daemon whose heap (kotlin.daemon.jvmargs) cannot fit two full :amethyst
|
||||
// codegen passes — CI runs died with "GC overhead limit exceeded" inside the
|
||||
// daemon. This no-op shared build service with maxParallelUsages = 1 tells the
|
||||
// scheduler to run this module's Kotlin compile tasks one at a time; other
|
||||
// projects' tasks (JVM tests, lint analysis, packaging) still run in parallel.
|
||||
//
|
||||
// CI-only: the OOM needs a cache-cold compile of several variants at once,
|
||||
// which local builds (incremental, usually one variant) don't produce.
|
||||
abstract class AmethystKotlinCompileLimiter : BuildService<BuildServiceParameters.None>
|
||||
|
||||
if (System.getenv("CI") != null) {
|
||||
val kotlinCompileLimiter =
|
||||
gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) {
|
||||
maxParallelUsages.set(1)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
usesService(kotlinCompileLimiter)
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
|
||||
@@ -0,0 +1,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()))
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ class EventSyncTest {
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
signWithAllLoggedInUsers = { _, authTemplate, _ ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
+9
-1
@@ -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))
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+20
-7
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model.privacyOptions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -32,7 +34,18 @@ import javax.net.SocketFactory
|
||||
class RoleBasedHttpClientBuilder(
|
||||
val okHttpClient: DualHttpClientManager,
|
||||
val torSettings: TorSettingsFlow,
|
||||
/**
|
||||
* When present, every role's client is wrapped with a byte-counting
|
||||
* interceptor so the resource-usage ledger can attribute HTTP traffic
|
||||
* per subsystem. Null keeps the raw shared clients (tests).
|
||||
*/
|
||||
val usageMeter: HttpUsageMeter? = null,
|
||||
) : IRoleBasedHttpClientBuilder {
|
||||
private fun metered(
|
||||
role: String,
|
||||
base: OkHttpClient,
|
||||
): OkHttpClient = usageMeter?.counted(role, base) ?: base
|
||||
|
||||
fun shouldUseTorForImageDownload(url: String) =
|
||||
shouldUseTorFor(
|
||||
url,
|
||||
@@ -131,19 +144,19 @@ class RoleBasedHttpClientBuilder(
|
||||
|
||||
override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url))
|
||||
|
||||
override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url))
|
||||
override fun okHttpClientForNip05(url: String): OkHttpClient = metered(UsageKeys.ROLE_NIP05, okHttpClient.getHttpClient(shouldUseTorForNIP05(url)))
|
||||
|
||||
override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url))
|
||||
override fun okHttpClientForUploads(url: String): OkHttpClient = metered(UsageKeys.ROLE_UPLOADS, okHttpClient.getHttpClient(shouldUseTorForUploads(url)))
|
||||
|
||||
override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))
|
||||
override fun okHttpClientForImage(url: String): OkHttpClient = metered(UsageKeys.ROLE_IMAGE, okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)))
|
||||
|
||||
override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))
|
||||
override fun okHttpClientForVideo(url: String): OkHttpClient = metered(UsageKeys.ROLE_VIDEO, okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)))
|
||||
|
||||
override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))
|
||||
override fun okHttpClientForMoney(url: String): OkHttpClient = metered(UsageKeys.ROLE_MONEY, okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)))
|
||||
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = metered(UsageKeys.ROLE_PREVIEW, okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)))
|
||||
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = metered(UsageKeys.ROLE_PUSH, okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()))
|
||||
|
||||
/**
|
||||
* Returns a [SocketFactory] that routes through the user's Tor proxy
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 =
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+60
-7
@@ -26,9 +26,11 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
@@ -47,15 +49,26 @@ import java.util.concurrent.TimeUnit
|
||||
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as
|
||||
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
|
||||
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
|
||||
*
|
||||
* The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP
|
||||
* observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and
|
||||
* [doWork] cancels the chain when the cache holds no accepted RSVP that could still start.
|
||||
* LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty
|
||||
* cache and can never fire a reminder — an unconditional periodic schedule would cold-start
|
||||
* the whole app graph every 15 minutes forever for zero benefit.
|
||||
*/
|
||||
class CalendarReminderWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) }
|
||||
val prefs = CalendarReminderPrefs(applicationContext)
|
||||
if (!prefs.isEnabled()) {
|
||||
Log.d(TAG) { "Reminders disabled; skipping scan." }
|
||||
Log.d(TAG) { "Reminders disabled; ending periodic chain." }
|
||||
// The settings toggle re-schedules on enable; no reason to keep
|
||||
// waking the process while the feature is off.
|
||||
cancel(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
val now = TimeUtils.now()
|
||||
@@ -66,12 +79,7 @@ class CalendarReminderWorker(
|
||||
// multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's
|
||||
// present in cache — the alternative (looking only at the foreground account) would
|
||||
// silently break notifications for account switching during the lead window.
|
||||
val acceptedRsvps =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
val acceptedRsvps = acceptedRsvpsInCache()
|
||||
|
||||
Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" }
|
||||
|
||||
@@ -110,6 +118,22 @@ class CalendarReminderWorker(
|
||||
|
||||
// Prune entries for events that ended more than a day ago — they can't fire again.
|
||||
store.forgetBefore(now - PRUNE_AGE_SECONDS)
|
||||
|
||||
// Nothing left that could ever fire → end the periodic chain instead of
|
||||
// waking the process every 15 minutes forever. The observers in
|
||||
// AppModules re-schedule the worker the next time a live session sees
|
||||
// an accepted RSVP or a calendar-event update.
|
||||
//
|
||||
// Decide on a FRESH cache snapshot, not the one from the start of the
|
||||
// run: an RSVP accepted while this run was scanning already fired the
|
||||
// observer, whose schedule() uses KEEP and no-ops while this chain
|
||||
// still exists — cancelling on the stale snapshot would kill the chain
|
||||
// with that RSVP's reminder permanently lost (observeNewEvents never
|
||||
// re-fires for an event that is already in cache).
|
||||
if (!couldStillFire(acceptedRsvpsInCache(), TimeUtils.now())) {
|
||||
Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." }
|
||||
cancel(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@@ -121,6 +145,35 @@ class CalendarReminderWorker(
|
||||
// more than a day ago; they can't fire again so the entry is pure overhead.
|
||||
private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L
|
||||
|
||||
/** Every ACCEPTED kind-31925 RSVP currently present in LocalCache. */
|
||||
fun acceptedRsvpsInCache(): List<CalendarRSVPEvent> =
|
||||
LocalCache.addressables
|
||||
.filterIntoSet { _, note ->
|
||||
val e = note.event
|
||||
e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED
|
||||
}.mapNotNull { it.event as? CalendarRSVPEvent }
|
||||
|
||||
/**
|
||||
* True while at least one accepted RSVP could still produce a reminder:
|
||||
* its target event either starts in the future, or hasn't been fetched
|
||||
* yet (start unknown — the chain must survive until the target
|
||||
* resolves). False means the periodic worker has nothing it could ever
|
||||
* notify about and may cancel its own chain.
|
||||
*/
|
||||
fun couldStillFire(
|
||||
rsvps: Collection<CalendarRSVPEvent>,
|
||||
now: Long,
|
||||
): Boolean =
|
||||
rsvps.any { rsvp ->
|
||||
val targetAddress = rsvp.calendarEventAddress() ?: return@any false
|
||||
val start =
|
||||
LocalCache.addressables
|
||||
.get(targetAddress)
|
||||
?.appointmentView()
|
||||
?.startSeconds
|
||||
start == null || start > now
|
||||
}
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<CalendarReminderWorker>(15, TimeUnit.MINUTES)
|
||||
|
||||
+3
@@ -34,6 +34,7 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
@@ -61,6 +62,7 @@ class CallForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.callSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
@@ -151,6 +153,7 @@ class CallForegroundService : Service() {
|
||||
// because CallManager.hangup() transitions to Ended and a second
|
||||
// hangup() from Ended state returns immediately.
|
||||
publishHangupBlocking()
|
||||
Amethyst.instance.callSession.setActive(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.crashreports
|
||||
|
||||
/**
|
||||
* Developer recipient for every user-initiated diagnostic NIP-17 DM — crash
|
||||
* reports and resource-usage reports both route here. Single definition so a
|
||||
* key rotation can never leave one path DMing the old key.
|
||||
*/
|
||||
const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"
|
||||
+1
-1
@@ -80,7 +80,7 @@ fun DisplayCrashMessages(
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser("aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"),
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = stack,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
|
||||
@@ -31,13 +31,17 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
class LocationState(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
|
||||
private val onListening: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 10000L
|
||||
@@ -72,6 +76,8 @@ class LocationState(
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
|
||||
}.onEach {
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
|
||||
@@ -106,6 +107,7 @@ class NestForegroundService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Amethyst.instance.nestsSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
wakeLock =
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
@@ -421,6 +423,7 @@ class NestForegroundService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Amethyst.instance.nestsSession.setActive(false)
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
wakeLock = null
|
||||
abandonAudioFocus()
|
||||
|
||||
+5
@@ -24,6 +24,7 @@ import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
@@ -108,6 +109,8 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
|
||||
|
||||
class EventNotificationConsumer(
|
||||
private val applicationContext: Context,
|
||||
/** Reports how long each notification-processing wakelock was held (resource-usage ledger). */
|
||||
private val onWakeLockHeld: ((heldMs: Long) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes
|
||||
@@ -126,6 +129,7 @@ class EventNotificationConsumer(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"amethyst:notification_processing",
|
||||
)
|
||||
val heldSince = SystemClock.elapsedRealtime()
|
||||
wakeLock.acquire(WAKELOCK_TIMEOUT_MS)
|
||||
try {
|
||||
return block()
|
||||
@@ -133,6 +137,7 @@ class EventNotificationConsumer(
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
onWakeLockHeld?.invoke(SystemClock.elapsedRealtime() - heldSince)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -120,6 +121,7 @@ class NotificationCatchUpWorker(
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.d(TAG, "Starting notification catch-up")
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("notificationCatchUp"), 1) }
|
||||
|
||||
return try {
|
||||
// If the foreground service should be running but isn't, restart it
|
||||
|
||||
+3
-1
@@ -80,6 +80,8 @@ import kotlinx.coroutines.launch
|
||||
class NotificationDispatcher(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
/** Forwarded to [EventNotificationConsumer]: reports wakelock held-time to the resource-usage ledger. */
|
||||
onWakeLockHeld: ((heldMs: Long) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NotificationDispatcher"
|
||||
@@ -127,7 +129,7 @@ class NotificationDispatcher(
|
||||
)
|
||||
}
|
||||
|
||||
private val consumer = EventNotificationConsumer(context)
|
||||
private val consumer = EventNotificationConsumer(context, onWakeLockHeld)
|
||||
private var job: Job? = null
|
||||
|
||||
fun start() {
|
||||
|
||||
+2
@@ -138,6 +138,7 @@ class NotificationRelayService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
Amethyst.instance.alwaysOnSession.setActive(true)
|
||||
createNotificationChannel()
|
||||
ensureForeground()
|
||||
}
|
||||
@@ -197,6 +198,7 @@ class NotificationRelayService : Service() {
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "Service destroyed")
|
||||
Amethyst.instance.alwaysOnSession.setActive(false)
|
||||
relayServiceCollectorJob?.cancel()
|
||||
scope.cancel()
|
||||
|
||||
|
||||
+6
-1
@@ -52,8 +52,13 @@ class ServiceWatchdogManager {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// ELAPSED_REALTIME (not _WAKEUP): a health check is not worth pulling
|
||||
// the CPU out of sleep — if the device is asleep, a restarted service
|
||||
// couldn't do useful network work anyway. The alarm fires as soon as
|
||||
// the device is next awake, and the deeper restart layers (15-min
|
||||
// WorkManager job, FCM/UnifiedPush) cover the force-stop/doze cases.
|
||||
alarmManager.setInexactRepeating(
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
AlarmManager.ELAPSED_REALTIME,
|
||||
SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS,
|
||||
WATCHDOG_INTERVAL_MS,
|
||||
pendingIntent,
|
||||
|
||||
+5
-1
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.Call
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.InetSocketAddress
|
||||
@@ -46,8 +47,11 @@ class DualHttpClientManager(
|
||||
// is uniform across image, upload, NIP-05, money, preview, and push roles.
|
||||
// See [OkHttpClientFactory] kdoc.
|
||||
onionCache: OnionLocationCache,
|
||||
// Resource-usage ledger counter, installed on the shared base client so
|
||||
// every derived client is accounted. See [OkHttpClientFactory].
|
||||
usageInterceptor: Interceptor? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+9
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
@@ -61,6 +62,13 @@ class OkHttpClientFactory(
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
private val onionCache: OnionLocationCache,
|
||||
/**
|
||||
* Resource-usage ledger counter, installed OUTERMOST on the shared base
|
||||
* client so every request through any derived client is accounted —
|
||||
* per-role tagging wrappers only relabel, they never bypass. Null in
|
||||
* tests / pre-configuration call sites.
|
||||
*/
|
||||
private val usageInterceptor: Interceptor? = null,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
@@ -103,6 +111,7 @@ class OkHttpClientFactory(
|
||||
.pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.apply { usageInterceptor?.let { addInterceptor(it) } }
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.apply {
|
||||
blossomCacheRedirect?.let { addInterceptor(it) }
|
||||
|
||||
+13
@@ -40,6 +40,19 @@ class OkHttpClientFactoryForRelays(
|
||||
const val DEFAULT_IS_MOBILE: Boolean = false
|
||||
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
|
||||
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
|
||||
|
||||
// 120s is a measured sweet spot, not a guess — see
|
||||
// amethyst/plans/2026-07-12-relay-ping-interval-study.md (probe of 122
|
||||
// production relays). Idle-timeout tiers observed in production are
|
||||
// ~60s / ~120s / ~240s / ~300s / ~600s, and a client ping only reliably
|
||||
// resets a relay's idle timer when the interval sits well BELOW the
|
||||
// tier (240s pings already lose the ~240s and ~300s tiers, incl. every
|
||||
// nostr1.com-hosted relay; 300s pings lose relay.snort.social). Raising
|
||||
// the interval also saves almost no battery: 90% of surveyed relays
|
||||
// send their own server pings every 30-70s, which OkHttp must answer,
|
||||
// so the radio's wake cadence is set by the relays, not by this value.
|
||||
// Lowering it would only rescue the ~120s tier (6/122 relays, already
|
||||
// cycling today) at 2x the ping traffic on every other connection.
|
||||
const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.repeat.AutoReplayLimiter
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@@ -82,8 +83,10 @@ class ExoPlayerBuilder(
|
||||
)
|
||||
PcmTapRegistry.bind(currentMediaItem?.mediaId, sink)
|
||||
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
||||
addListener(AutoReplayLimiter(pause = ::pause))
|
||||
addListener(KeepVideosPlaying(this))
|
||||
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
|
||||
addListener(MediaPlayTimeTracker())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
|
||||
/**
|
||||
* Accounts actual media playback time into the resource-usage ledger.
|
||||
* Playback is one of the most energy-dense things the app does — decoder,
|
||||
* screen, and streaming all at once — and this turns "video used 800 MB"
|
||||
* into "800 MB over 2h of playback" (normal) vs "over 10 minutes" (a bug).
|
||||
*
|
||||
* Attached once per player in [ExoPlayerBuilder]; a player released
|
||||
* mid-playback loses at most its final open segment.
|
||||
*/
|
||||
class MediaPlayTimeTracker(
|
||||
private val onPlayed: (elapsedMs: Long) -> Unit = { ms ->
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.MEDIA_PLAY_MS, ms) }
|
||||
},
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : Player.Listener {
|
||||
private var playingSinceMs = Long.MIN_VALUE
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
val now = nowMs()
|
||||
if (isPlaying) {
|
||||
playingSinceMs = now
|
||||
} else if (playingSinceMs != Long.MIN_VALUE) {
|
||||
onPlayed(now - playingSinceMs)
|
||||
playingSinceMs = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.playerPool.repeat
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
|
||||
/**
|
||||
* Caps how many times a video loops on its own under [Player.REPEAT_MODE_ONE].
|
||||
*
|
||||
* Feed videos are configured to repeat forever (keepPlaying → REPEAT_MODE_ONE in
|
||||
* PlaybackService), which keeps decoders, network and the screen busy long after the
|
||||
* user stopped watching. This listener lets a video play [maxAutoPlays] full times and
|
||||
* then pauses it, so continuing requires an explicit press of the play button.
|
||||
*
|
||||
* Each loop under REPEAT_MODE_ONE surfaces as an [onMediaItemTransition] with
|
||||
* [Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT], marking one completed play. The
|
||||
* transition has already seeked back to the start, so pausing there leaves the video
|
||||
* on its first frame with the play button showing. Any resume — the user pressing
|
||||
* play, or the feed mutex auto-playing when the video scrolls back to the center —
|
||||
* grants a fresh allowance, and switching to a different media item resets it too.
|
||||
*/
|
||||
class AutoReplayLimiter(
|
||||
val maxAutoPlays: Int = DEFAULT_MAX_AUTO_PLAYS,
|
||||
val pause: () -> Unit,
|
||||
) : Player.Listener {
|
||||
private var playsCompleted = 0
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) {
|
||||
playsCompleted++
|
||||
if (playsCompleted >= maxAutoPlays) {
|
||||
pause()
|
||||
}
|
||||
} else {
|
||||
playsCompleted = 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(
|
||||
playWhenReady: Boolean,
|
||||
reason: Int,
|
||||
) {
|
||||
if (playWhenReady) {
|
||||
playsCompleted = 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_AUTO_PLAYS = 5
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-44
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+128
-47
@@ -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()
|
||||
}
|
||||
|
||||
+14
-5
@@ -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:"
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+56
@@ -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
|
||||
}
|
||||
}
|
||||
+105
@@ -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()
|
||||
}
|
||||
+5
-15
@@ -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(
|
||||
|
||||
+85
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+15
@@ -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
@@ -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,
|
||||
|
||||
+24
-11
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.Nickname
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
@@ -43,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -59,18 +61,29 @@ fun observeUserName(
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.metadata()
|
||||
.flow
|
||||
.map {
|
||||
it?.info?.bestName() ?: user.pubkeyDisplayHex()
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
val contactCards = accountViewModel.account.contactCards
|
||||
val flow = remember(user) { contactCards.displayNameFlow(user) }
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
|
||||
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) })
|
||||
}
|
||||
|
||||
/**
|
||||
* The nickname (NIP-85 petname + private summary) the logged-in account gave
|
||||
* this user through its own contact card, decrypted from the card's content,
|
||||
* with the card's tags so `:shortcode:` custom emojis resolve. Null when the
|
||||
* account never nicknamed this user. Per the spec, the petname should be
|
||||
* rendered instead of the user's display name.
|
||||
*/
|
||||
@Composable
|
||||
fun observeUserNickname(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<Nickname?> {
|
||||
val contactCards = accountViewModel.account.contactCards
|
||||
val flow = remember(user) { contactCards.nicknameFlow(user) }
|
||||
|
||||
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) })
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -296,7 +309,7 @@ fun observeUserBookmarkCount(
|
||||
|
||||
val combined =
|
||||
remember(user) {
|
||||
kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount ->
|
||||
combine(newFlow, oldFlow) { newCount, oldCount ->
|
||||
newCount + oldCount
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.toHexSet
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -32,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
class UserCardsSubAssembler(
|
||||
@@ -45,7 +47,10 @@ class UserCardsSubAssembler(
|
||||
filters: List<Filter>?,
|
||||
) {
|
||||
filters?.forEach { filter ->
|
||||
filter.tags?.get("p")?.forEach {
|
||||
// kind:30382 addresses the target user in the d-tag (the key the
|
||||
// filter builder uses). Reading any other tag leaves the per-user
|
||||
// EOSEs unset and forces full re-downloads with since = null.
|
||||
filter.tags?.get(DTag.TAG_NAME)?.forEach {
|
||||
val targetUser = cache.getUserIfExists(it)
|
||||
targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time)
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
/**
|
||||
* Samples the device battery level at each ledger flush and accumulates the
|
||||
* drops that happen while discharging into [UsageKeys.BATTERY_DRAIN_FG] /
|
||||
* [UsageKeys.BATTERY_DRAIN_BG] (percent points, attributed by visibility at
|
||||
* sample time). This is deliberately NOT app-isolated — it's the measured
|
||||
* ground truth that lets the developers correlate the app's own counters
|
||||
* against real drain across many reports, which is how the alert thresholds
|
||||
* get tuned.
|
||||
*
|
||||
* Intervals touching a charging state (at either endpoint) are skipped, and
|
||||
* a level that went UP just resets the baseline. Piggybacks on the pre-flush
|
||||
* hook — one BatteryManager binder read per flush, nothing while idle.
|
||||
*/
|
||||
class BatteryDrainSampler(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val capacityPct: () -> Int?,
|
||||
private val isCharging: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
) {
|
||||
private var lastPct: Int? = null
|
||||
private var lastCharging = true
|
||||
|
||||
fun register() {
|
||||
accountant.addPreFlushHook(::sample)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sample() {
|
||||
val pct = capacityPct() ?: return
|
||||
val charging = isCharging()
|
||||
val prevPct = lastPct
|
||||
val prevCharging = lastCharging
|
||||
lastPct = pct
|
||||
lastCharging = charging
|
||||
|
||||
if (prevPct == null || prevCharging || charging) return
|
||||
val drop = prevPct - pct
|
||||
if (drop <= 0) return
|
||||
accountant.add(
|
||||
if (isForeground()) UsageKeys.BATTERY_DRAIN_FG else UsageKeys.BATTERY_DRAIN_BG,
|
||||
drop.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.collectMemorySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* On app open, checks the resource-usage ledger against the
|
||||
* [ResourceUsageAlerts] thresholds and — at most once per week, unless the
|
||||
* user opted out — asks whether they'd like to review + send a usage report
|
||||
* to the developers. Confirming only PREFILLS the NIP-17 DM composer (crash
|
||||
* report pattern): the full report text is visible there and nothing is sent
|
||||
* until the user taps Send.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayResourceUsageAlert(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val alert = remember { mutableStateOf<ResourceUsageAlerts.Alert?>(null) }
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val store = Amethyst.instance.resourceUsageStore
|
||||
val accountant = Amethyst.instance.resourceUsage
|
||||
if (!ResourceUsageAlerts.shouldPrompt(store.lastAlertAtSec(), store.alertsOptOut(), TimeUtils.now())) {
|
||||
return@withContext
|
||||
}
|
||||
val found = ResourceUsageAlerts.evaluate(accountant.allDaysIncludingLive(), accountant.today())
|
||||
if (found != null) {
|
||||
alert.value = found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 7-day rate limit is consumed when the user ACTS on the dialog (any
|
||||
// button or dismissal), not when it is shown: marking before the first
|
||||
// frame let a config change or process death burn the whole prompt budget
|
||||
// on a dialog nobody ever saw, silencing an active battery problem for a
|
||||
// week. Re-showing after an unhandled recreation is exactly what we want.
|
||||
val dismiss = {
|
||||
accountViewModel.runOnIO {
|
||||
Amethyst.instance.resourceUsageStore.markAlertPrompted(TimeUtils.now())
|
||||
}
|
||||
alert.value = null
|
||||
}
|
||||
|
||||
alert.value?.let { found ->
|
||||
AlertDialog(
|
||||
onDismissRequest = dismiss,
|
||||
title = { Text(stringRes(R.string.resource_usage_alert_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
R.string.resource_usage_alert_message,
|
||||
reasonDescription(found),
|
||||
),
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = {
|
||||
accountViewModel.runOnIO {
|
||||
Amethyst.instance.resourceUsageStore.setAlertsOptOut(true)
|
||||
}
|
||||
dismiss()
|
||||
}) {
|
||||
Text(stringRes(R.string.resource_usage_alert_opt_out))
|
||||
}
|
||||
TextButton(onClick = dismiss) {
|
||||
Text(stringRes(R.string.resource_usage_alert_not_now))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
nav.nav {
|
||||
val report =
|
||||
withContext(Dispatchers.IO) {
|
||||
ResourceUsageReportAssembler().buildReport(
|
||||
Amethyst.instance.resourceUsage.allDaysIncludingLive(),
|
||||
Amethyst.instance.resourceUsage.today(),
|
||||
collectMemorySnapshot(context),
|
||||
)
|
||||
}
|
||||
routeToMessage(
|
||||
user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY),
|
||||
draftMessage = report,
|
||||
accountViewModel = accountViewModel,
|
||||
expiresDays = 30,
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}) {
|
||||
Text(stringRes(R.string.resource_usage_alert_send))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String =
|
||||
when (alert.reason) {
|
||||
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA ->
|
||||
stringRes(
|
||||
R.string.resource_usage_reason_bg_data,
|
||||
ResourceUsageReportAssembler.formatBytes(alert.value),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME ->
|
||||
stringRes(
|
||||
R.string.resource_usage_reason_conn_time,
|
||||
ResourceUsageReportAssembler.formatConnHours(alert.value),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.WAKELOCK_TIME ->
|
||||
stringRes(
|
||||
R.string.resource_usage_reason_wakelock,
|
||||
ResourceUsageReportAssembler.formatDurationMs(alert.value),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.PROCESS_CHURN ->
|
||||
pluralStringResource(
|
||||
R.plurals.resource_usage_reason_churn,
|
||||
alert.value.toInt(),
|
||||
alert.value.toInt(),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.RECONNECT_CHURN ->
|
||||
pluralStringResource(
|
||||
R.plurals.resource_usage_reason_reconnects,
|
||||
alert.value.toInt(),
|
||||
alert.value.toInt(),
|
||||
)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Integrates time-with-UI-visible into the ledger ([UsageKeys.APP_FG_MS]).
|
||||
* Screen-on time is what display power is proportional to, and it's the
|
||||
* denominator that makes every other counter interpretable (MB per hour in
|
||||
* app vs MB while backgrounded).
|
||||
*/
|
||||
class ForegroundTimeIntegrator(
|
||||
private val isForeground: Flow<Boolean>,
|
||||
accountant: ResourceUsageAccountant,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<Unit>(accountant, nowMs) {
|
||||
fun start(scope: CoroutineScope): Job {
|
||||
registerFlushHook()
|
||||
return scope.launch {
|
||||
isForeground.collect { fg -> transitionTo(if (fg) Unit else null) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: Unit,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (elapsedMs > 0) accountant.add(UsageKeys.APP_FG_MS, elapsedMs)
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Process-level foreground signal as an observable StateFlow: true while at
|
||||
* least one activity is STARTED. Used by the usage ledger to attribute bytes
|
||||
* and connection-time to foreground vs background buckets.
|
||||
*
|
||||
* ([MainActivity.isResumed] is not observable and goes false during PiP /
|
||||
* in-app dialogs, which would misattribute foreground traffic to background.)
|
||||
*/
|
||||
class ForegroundTracker : Application.ActivityLifecycleCallbacks {
|
||||
private var startedActivities = 0
|
||||
|
||||
private val _isForeground = MutableStateFlow(false)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
startedActivities++
|
||||
_isForeground.value = startedActivities > 0
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedActivities = (startedActivities - 1).coerceAtLeast(0)
|
||||
_isForeground.value = startedActivities > 0
|
||||
}
|
||||
|
||||
override fun onActivityCreated(
|
||||
activity: Activity,
|
||||
savedInstanceState: Bundle?,
|
||||
) {}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
|
||||
override fun onActivitySaveInstanceState(
|
||||
activity: Activity,
|
||||
outState: Bundle,
|
||||
) {}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
|
||||
/**
|
||||
* A [NostrSigner] decorator (same pattern as [NostrSignerWithClientTag]) that
|
||||
* accounts signing and encryption work into the resource-usage ledger:
|
||||
*
|
||||
* - signature counts by signer kind — a local-key signature is ~free, a
|
||||
* NIP-55 one wakes the Amber process over IPC, and a NIP-46 one is a full
|
||||
* relay round-trip, so the *kind* is the battery-relevant dimension;
|
||||
* - NIP-04/44 decrypt/encrypt counts, with CPU time metered only when the
|
||||
* wrapped signer is a local key ([NostrSignerInternal]) — for external and
|
||||
* remote signers the elapsed time would be IPC/network wait, not crypto
|
||||
* cost, and would poison the CPU numbers.
|
||||
*
|
||||
* Overhead per operation: one counter increment (plus two [System.nanoTime]
|
||||
* calls for local-key crypto, nanoseconds against a millisecond-scale ECDH) —
|
||||
* nothing here can slow the operations being measured.
|
||||
*
|
||||
* Wrapped INSIDE [NostrSignerWithClientTag] (metering the raw signer), so the
|
||||
* existing `signer is NostrSignerWithClientTag` call sites keep working;
|
||||
* anything that needs the raw signer should unwrap via [innermostSigner].
|
||||
*/
|
||||
class MeteringNostrSigner(
|
||||
val inner: NostrSigner,
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
) : NostrSigner(inner.pubKey) {
|
||||
private val signKey = UsageKeys.signs(signerKind(inner))
|
||||
private val meterCryptoTime = inner is NostrSignerInternal
|
||||
|
||||
override fun isWriteable(): Boolean = inner.isWriteable()
|
||||
|
||||
override suspend fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T {
|
||||
accountant.add(signKey, 1)
|
||||
return inner.sign(createdAt, kind, tags, content)
|
||||
}
|
||||
|
||||
override suspend fun nip04Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip04Encrypt(plaintext, toPublicKey) }
|
||||
|
||||
override suspend fun nip04Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip04Decrypt(ciphertext, fromPublicKey) }
|
||||
|
||||
override suspend fun nip44Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip44Encrypt(plaintext, toPublicKey) }
|
||||
|
||||
override suspend fun nip44Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip44Decrypt(ciphertext, fromPublicKey) }
|
||||
|
||||
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.decryptZapEvent(event) }
|
||||
|
||||
override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce)
|
||||
|
||||
override suspend fun signPsbt(psbtHex: String): String {
|
||||
accountant.add(signKey, 1)
|
||||
return inner.signPsbt(psbtHex)
|
||||
}
|
||||
|
||||
override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport()
|
||||
|
||||
private inline fun <T> metered(
|
||||
countKey: String,
|
||||
usKey: String,
|
||||
op: () -> T,
|
||||
): T {
|
||||
accountant.add(countKey, 1)
|
||||
if (!meterCryptoTime) return op()
|
||||
val start = System.nanoTime()
|
||||
try {
|
||||
return op()
|
||||
} finally {
|
||||
accountant.add(usKey, (System.nanoTime() - start) / 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun signerKind(signer: NostrSigner): String =
|
||||
when (signer) {
|
||||
is NostrSignerExternal -> UsageKeys.SIGNER_NIP55
|
||||
is NostrSignerRemote -> UsageKeys.SIGNER_NIP46
|
||||
else -> UsageKeys.SIGNER_LOCAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the app's signer decorators (client tag, metering) to reach the
|
||||
* concrete signer — for call sites that need the real type, like the NIP-55
|
||||
* activity-launcher registration.
|
||||
*/
|
||||
fun NostrSigner.innermostSigner(): NostrSigner =
|
||||
when (this) {
|
||||
is NostrSignerWithClientTag -> inner.innermostSigner()
|
||||
is MeteringNostrSigner -> inner.innermostSigner()
|
||||
else -> this
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.Process
|
||||
|
||||
/**
|
||||
* Samples whole-process CPU time (user+system, [Process.getElapsedCpuTime])
|
||||
* into the ledger as day deltas. This is the honest aggregate cost of
|
||||
* everything that runs on the CPU — event parsing, signature verification,
|
||||
* coroutines, recomposition — without per-subsystem guesswork. Sampled from
|
||||
* the accountant's pre-flush hook, so it costs one syscall per flush and
|
||||
* nothing while idle. Per-process monotonic: a fresh process simply starts a
|
||||
* fresh baseline.
|
||||
*/
|
||||
class ProcessCpuSampler(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val cpuMs: () -> Long = { Process.getElapsedCpuTime() },
|
||||
) {
|
||||
private var lastSampleMs = cpuMs()
|
||||
|
||||
fun register() {
|
||||
accountant.addPreFlushHook(::sample)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sample() {
|
||||
val now = cpuMs()
|
||||
val delta = now - lastSampleMs
|
||||
lastSampleMs = now
|
||||
if (delta > 0) accountant.add(UsageKeys.CPU_MS, delta)
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Integrates relay-connection-time (Σ open connections × elapsed time) into
|
||||
* the ledger, split by network class and visibility. Connection-time is the
|
||||
* best single battery proxy the ping study found: most relays server-ping
|
||||
* every 30-70s, so the radio is active for as long as connections are open.
|
||||
*/
|
||||
class RelayConnectionTimeIntegrator(
|
||||
private val connectedCount: Flow<Int>,
|
||||
private val isMobile: Flow<Boolean?>,
|
||||
private val isForeground: Flow<Boolean>,
|
||||
accountant: ResourceUsageAccountant,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<RelayConnectionTimeIntegrator.SegmentState>(accountant, nowMs) {
|
||||
data class SegmentState(
|
||||
val count: Int,
|
||||
val mobile: Boolean,
|
||||
val foreground: Boolean,
|
||||
)
|
||||
|
||||
fun start(scope: CoroutineScope): Job {
|
||||
registerFlushHook()
|
||||
return scope.launch {
|
||||
combine(connectedCount, isMobile, isForeground) { count, mobile, fg ->
|
||||
SegmentState(count, mobile ?: false, fg)
|
||||
}.collect { next -> transitionTo(next) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: SegmentState,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (state.count <= 0 || elapsedMs <= 0) return
|
||||
accountant.add(UsageKeys.relayConnMs(state.mobile, state.foreground), state.count * elapsedMs)
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
|
||||
/**
|
||||
* Counts relay websocket traffic into the usage ledger. Frame sizes are
|
||||
* UTF-16 char counts of the JSON payload — a close proxy for on-wire bytes
|
||||
* (relay JSON is ASCII-dominant), consistent with how RelayStats counts.
|
||||
* Excludes WS framing/compression; good enough for "which subsystem is
|
||||
* eating my data plan" comparisons.
|
||||
*/
|
||||
class RelayUsageListener(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
) : RelayConnectionListener {
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
if (success) {
|
||||
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong())
|
||||
}
|
||||
|
||||
// Every completed (re)connection paid a TCP+TLS handshake; high daily
|
||||
// counts are the signature of reconnect churn (flaky network, aggressive
|
||||
// relay idle timeouts, Tor bootstrap loops).
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1)
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* In-memory hot path for usage counters. [add] is called from network threads
|
||||
* per relay frame / HTTP response chunk, so it must be allocation-light and
|
||||
* lock-free: a ConcurrentHashMap of AtomicLongs, drained into
|
||||
* [ResourceUsageStore] by a debounced flush (at most one write per
|
||||
* [flushDebounceMs] while traffic flows; nothing scheduled when idle).
|
||||
*
|
||||
* AtomicLong (not LongAdder) because draining must be loss-free: getAndSet(0)
|
||||
* hands off the accumulated value atomically, whereas remove+sum on a
|
||||
* LongAdder can strand a racing increment on an orphaned cell. Entries stay
|
||||
* in the map after a drain — the key space is small and fixed (dims x areas),
|
||||
* so this costs a few hundred boxed zeros at most.
|
||||
*
|
||||
* Counters added from inside a pre-flush hook (the CPU sampler, the segment
|
||||
* integrators closing an open segment) never re-arm the debounce: they are
|
||||
* drained by the very flush that invoked the hook, and letting them schedule
|
||||
* would turn every flush into a perpetual 30s wake-and-write loop — the
|
||||
* battery ledger becoming its own battery drain.
|
||||
*
|
||||
* Day attribution: deltas are drained into the bucket of the day they are
|
||||
* drained on. Counts within one debounce window of midnight may land on the
|
||||
* neighboring day — irrelevant at the ledger's day-level granularity.
|
||||
*/
|
||||
class ResourceUsageAccountant(
|
||||
private val store: ResourceUsageStore,
|
||||
private val scope: CoroutineScope,
|
||||
private val epochDay: () -> Long = { System.currentTimeMillis() / DAY_MS },
|
||||
private val flushDebounceMs: Long = 30_000L,
|
||||
) {
|
||||
private val live = ConcurrentHashMap<String, AtomicLong>()
|
||||
private val flushScheduled = AtomicBoolean(false)
|
||||
|
||||
/** True only on the thread currently running the pre-flush hooks. */
|
||||
private val inHookRun = ThreadLocal.withInitial { false }
|
||||
|
||||
/** Hooks run right before a flush drains the counters (e.g. the connection-time integrator closing its open segment). */
|
||||
private val preFlushHooks = ConcurrentHashMap.newKeySet<() -> Unit>()
|
||||
|
||||
fun addPreFlushHook(hook: () -> Unit) {
|
||||
preFlushHooks.add(hook)
|
||||
}
|
||||
|
||||
fun add(
|
||||
key: String,
|
||||
amount: Long,
|
||||
) {
|
||||
if (amount <= 0) return
|
||||
live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount)
|
||||
if (inHookRun.get()) return
|
||||
if (flushScheduled.compareAndSet(false, true)) {
|
||||
scope.launch {
|
||||
delay(flushDebounceMs)
|
||||
flushScheduled.set(false)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPreFlushHooks() {
|
||||
inHookRun.set(true)
|
||||
try {
|
||||
preFlushHooks.forEach { runCatching { it() } }
|
||||
} finally {
|
||||
inHookRun.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drains the in-memory counters into today's persisted bucket. */
|
||||
suspend fun flush() {
|
||||
runPreFlushHooks()
|
||||
val deltas = drain()
|
||||
if (deltas.isNotEmpty()) {
|
||||
store.mergeInto(epochDay(), deltas)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget flush for non-suspending callers (onTrimMemory). */
|
||||
fun flushAsync() {
|
||||
scope.launch { flush() }
|
||||
}
|
||||
|
||||
fun today(): Long = epochDay()
|
||||
|
||||
/**
|
||||
* Persisted buckets merged with the not-yet-flushed live counters
|
||||
* (attributed to today). This is what the UI, the report assembler,
|
||||
* and the alert evaluator read. Reading never schedules a flush.
|
||||
*/
|
||||
suspend fun allDaysIncludingLive(): Map<Long, Map<String, Long>> {
|
||||
runPreFlushHooks()
|
||||
val persisted = store.allDays()
|
||||
val liveSnapshot = live.mapValues { it.value.get() }.filterValues { it > 0 }
|
||||
if (liveSnapshot.isEmpty()) return persisted
|
||||
val today = epochDay()
|
||||
val merged = persisted.toMutableMap()
|
||||
val todayBucket = merged[today].orEmpty().toMutableMap()
|
||||
liveSnapshot.forEach { (key, value) -> todayBucket[key] = (todayBucket[key] ?: 0L) + value }
|
||||
merged[today] = todayBucket
|
||||
return merged
|
||||
}
|
||||
|
||||
private fun drain(): Map<String, Long> {
|
||||
if (live.isEmpty()) return emptyMap()
|
||||
val deltas = mutableMapOf<String, Long>()
|
||||
for ((key, counter) in live) {
|
||||
val value = counter.getAndSet(0L)
|
||||
if (value > 0) deltas[key] = value
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DAY_MS = 24L * 60L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
/**
|
||||
* Pure threshold logic for the "this app is consuming too much" prompt.
|
||||
* Thresholds are deliberately conservative: the prompt should fire for the
|
||||
* pathological cases (a stuck reconnect loop, process-restart churn, runaway
|
||||
* background sync) — not for a heavy day of normal use. Tune them as real
|
||||
* reports come in.
|
||||
*
|
||||
* Never auto-sends anything: a positive evaluation only ASKS the user, at
|
||||
* most once every [MIN_DAYS_BETWEEN_PROMPTS] days, and respects a permanent
|
||||
* opt-out. Both are persisted in [ResourceUsageStore].
|
||||
*/
|
||||
object ResourceUsageAlerts {
|
||||
enum class Reason {
|
||||
BACKGROUND_MOBILE_DATA,
|
||||
BACKGROUND_MOBILE_CONNECTION_TIME,
|
||||
WAKELOCK_TIME,
|
||||
PROCESS_CHURN,
|
||||
RECONNECT_CHURN,
|
||||
}
|
||||
|
||||
data class Alert(
|
||||
val reason: Reason,
|
||||
val day: Long,
|
||||
val value: Long,
|
||||
)
|
||||
|
||||
/** > 50 MB of background traffic on cellular in one day. */
|
||||
const val BG_MOBILE_BYTES_PER_DAY = 50L * 1024L * 1024L
|
||||
|
||||
/** > 12 relay-connection-hours while backgrounded on cellular in one day. */
|
||||
const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 12L * 60L * 60L * 1000L
|
||||
|
||||
/** > 30 minutes of notification wakelock held in one day. */
|
||||
const val WAKELOCK_MS_PER_DAY = 30L * 60L * 1000L
|
||||
|
||||
/** > 75 process starts in one day (WorkManager/restart churn). */
|
||||
const val APP_STARTS_PER_DAY = 75L
|
||||
|
||||
/**
|
||||
* > 5000 completed relay (re)connections in one day. A healthy day is a
|
||||
* few hundred to ~2000 even with a large relay set; sustained thousands
|
||||
* means something is cycling (a stuck relay tier, a flapping network, a
|
||||
* Tor bootstrap loop) and every cycle pays a TLS handshake.
|
||||
*/
|
||||
const val RELAY_CONNECTS_PER_DAY = 5_000L
|
||||
|
||||
const val MIN_DAYS_BETWEEN_PROMPTS = 7L
|
||||
|
||||
/**
|
||||
* Checks yesterday (the last complete day) first, then today (so a
|
||||
* runaway condition surfaces without waiting for midnight). Returns the
|
||||
* first threshold crossed or null.
|
||||
*/
|
||||
fun evaluate(
|
||||
days: Map<Long, Map<String, Long>>,
|
||||
today: Long,
|
||||
): Alert? {
|
||||
for (day in longArrayOf(today - 1, today)) {
|
||||
val counters = days[day] ?: continue
|
||||
val summary = UsageSummary.from(counters)
|
||||
|
||||
if (summary.mobileBytesBg > BG_MOBILE_BYTES_PER_DAY) {
|
||||
return Alert(Reason.BACKGROUND_MOBILE_DATA, day, summary.mobileBytesBg)
|
||||
}
|
||||
if (summary.relayConnMsMobileBg > BG_MOBILE_RELAY_CONN_MS_PER_DAY) {
|
||||
return Alert(Reason.BACKGROUND_MOBILE_CONNECTION_TIME, day, summary.relayConnMsMobileBg)
|
||||
}
|
||||
if (summary.wakelockMs > WAKELOCK_MS_PER_DAY) {
|
||||
return Alert(Reason.WAKELOCK_TIME, day, summary.wakelockMs)
|
||||
}
|
||||
if (summary.appStarts > APP_STARTS_PER_DAY) {
|
||||
return Alert(Reason.PROCESS_CHURN, day, summary.appStarts)
|
||||
}
|
||||
if (summary.relayConnects > RELAY_CONNECTS_PER_DAY) {
|
||||
return Alert(Reason.RECONNECT_CHURN, day, summary.relayConnects)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun shouldPrompt(
|
||||
lastAlertAtSec: Long,
|
||||
optOut: Boolean,
|
||||
nowSec: Long,
|
||||
): Boolean = !optOut && nowSec - lastAlertAtSec >= MIN_DAYS_BETWEEN_PROMPTS * 24L * 60L * 60L
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.MemorySnapshot
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Assembles the Markdown resource-usage report the user can DM to the
|
||||
* developers via NIP-17 — same shape as the crash ReportAssembler: a device
|
||||
* header table, a human-readable summary, then the full per-day counter dump
|
||||
* as the technical payload. Counters are sizes/durations/counts only; no
|
||||
* URLs, relay names, or content.
|
||||
*/
|
||||
class ResourceUsageReportAssembler {
|
||||
fun buildReport(
|
||||
days: Map<Long, Map<String, Long>>,
|
||||
today: Long,
|
||||
memory: MemorySnapshot? = null,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("Resource Usage Report: ")
|
||||
sb.append(BuildConfig.VERSION_NAME)
|
||||
sb.append("-")
|
||||
sb.append(BuildConfig.FLAVOR.uppercase())
|
||||
sb.append("\n\n")
|
||||
|
||||
sb.append("| Prop | Value |\n")
|
||||
sb.append("| --- | --- |\n")
|
||||
sb.append("| Manuf | ${Build.MANUFACTURER} |\n")
|
||||
sb.append("| Model | ${Build.MODEL} |\n")
|
||||
sb.append("| Android | ${Build.VERSION.RELEASE} |\n")
|
||||
sb.append("| SDK Int | ${Build.VERSION.SDK_INT} |\n")
|
||||
sb.append("\n")
|
||||
|
||||
val todayCounters = days[today].orEmpty()
|
||||
val weekCounters = (today - 6..today).mapNotNull { days[it] }
|
||||
|
||||
sb.append("**Today**\n\n")
|
||||
sb.append(summaryTable(UsageSummary.from(todayCounters)))
|
||||
sb.append("\n**Last 7 days**\n\n")
|
||||
sb.append(summaryTable(UsageSummary.fromDays(weekCounters)))
|
||||
|
||||
if (memory != null) {
|
||||
sb.append("\n**Memory right now**\n\n")
|
||||
sb.append("| Metric | Value |\n")
|
||||
sb.append("| --- | --- |\n")
|
||||
sb.append("| Device class | ${memory.memoryClassMb} MB |\n")
|
||||
sb.append("| App heap | ${memory.heapUsedMb} / ${memory.heapMaxMb} MB |\n")
|
||||
sb.append("| Native heap | ${memory.nativeHeapUsedMb} MB |\n")
|
||||
sb.append("| Image cache (RAM) | ${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB |\n")
|
||||
sb.append("| Image cache (disk) | ${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB |\n")
|
||||
sb.append("| Cached notes/users/addressables/chatrooms | ${memory.noteCount}/${memory.userCount}/${memory.addressableCount}/${memory.chatroomCount} |\n")
|
||||
}
|
||||
|
||||
sb.append("\nTechnical details (per epoch-day):\n")
|
||||
sb.append("```\n")
|
||||
days.toSortedMap().forEach { (day, counters) ->
|
||||
sb.append("day $day (today=$today)\n")
|
||||
counters.toSortedMap().forEach { (key, value) ->
|
||||
sb.append(" $key = $value\n")
|
||||
}
|
||||
}
|
||||
sb.append("```\n")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun summaryTable(s: UsageSummary): String =
|
||||
buildString {
|
||||
append("| Metric | Value |\n")
|
||||
append("| --- | --- |\n")
|
||||
append("| Cellular data (background) | ${formatBytes(s.mobileBytesBg)} |\n")
|
||||
append("| Cellular data (foreground) | ${formatBytes(s.mobileBytesFg)} |\n")
|
||||
append("| Wi-Fi data | ${formatBytes(s.wifiBytesBg + s.wifiBytesFg)} |\n")
|
||||
append("| Relay connection time | ${formatConnHours(s.relayConnMs)} |\n")
|
||||
append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n")
|
||||
append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n")
|
||||
append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n")
|
||||
append("| Web requests | ${s.httpRequests} (${s.radioBursts} radio bursts, ${formatDurationMs(s.httpActiveMs)} active) |\n")
|
||||
append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n")
|
||||
append("| PoW mining | ${formatDurationMs(s.powMs)} |\n")
|
||||
append("| Tor uptime (in-app) | ${formatDurationMs(s.torMs)} |\n")
|
||||
append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} (${s.alwaysOnStarts} starts) |\n")
|
||||
append("| Calls / audio rooms | ${formatDurationMs(s.callMs)} / ${formatDurationMs(s.nestsMs)} |\n")
|
||||
append("| Location listening | ${formatDurationMs(s.locationMs)} |\n")
|
||||
append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n")
|
||||
append("| Decryptions | ${s.decryptCount} (${formatDurationMs(s.decryptUs / 1_000)} CPU) |\n")
|
||||
append("| Remote signatures | ${s.signNip46} NIP-46, ${s.signNip55} NIP-55 |\n")
|
||||
append("| Battery drain (measured, whole device) | ${s.batteryDrainFg}% in app, ${s.batteryDrainBg}% background |\n")
|
||||
append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n")
|
||||
append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n")
|
||||
append("| Background worker runs | ${s.workerRuns} |\n")
|
||||
append("| App process starts | ${s.appStarts} |\n")
|
||||
val subsystems =
|
||||
s.bytesPerSubsystem.entries
|
||||
.sortedByDescending { it.value }
|
||||
.joinToString(", ") { "${it.key} ${formatBytes(it.value)}" }
|
||||
if (subsystems.isNotEmpty()) {
|
||||
append("| By subsystem | $subsystems |\n")
|
||||
}
|
||||
val screens =
|
||||
s.screenTimeMs.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(8)
|
||||
.joinToString(", ") { "${it.key} ${formatDurationMs(it.value)}" }
|
||||
if (screens.isNotEmpty()) {
|
||||
append("| Screen time | $screens |\n")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun formatBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
|
||||
bytes >= 1024L * 1024L -> String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0))
|
||||
bytes >= 1024L -> String.format(Locale.US, "%.1f KB", bytes / 1024.0)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
|
||||
/** Relay-connection time: Σ connections x time, so shown as "relay-hours". */
|
||||
fun formatConnHours(ms: Long): String = String.format(Locale.US, "%.1f relay-hours", ms / (1000.0 * 60.0 * 60.0))
|
||||
|
||||
fun formatDurationMs(ms: Long): String =
|
||||
when {
|
||||
ms >= 60L * 60L * 1000L -> String.format(Locale.US, "%.1f h", ms / (1000.0 * 60.0 * 60.0))
|
||||
ms >= 60L * 1000L -> String.format(Locale.US, "%.1f min", ms / (1000.0 * 60.0))
|
||||
else -> String.format(Locale.US, "%.1f s", ms / 1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Durable daily buckets for the resource-usage ledger, one JSON file in the
|
||||
* app's private filesDir. Same persistence idiom as ScheduledPostStore:
|
||||
* Jackson + Mutex + write-to-tmp-then-rename + version envelope.
|
||||
*
|
||||
* Day keys are UTC epoch-days (stringified for JSON). Buckets older than
|
||||
* [keepDays] are pruned on every merge, so the file stays small (a few KB).
|
||||
* Also carries the high-consumption alert state (last prompt time, opt-out)
|
||||
* so the whole feature has exactly one file.
|
||||
*/
|
||||
class ResourceUsageStore(
|
||||
private val storageFile: File,
|
||||
private val keepDays: Long = 30,
|
||||
) {
|
||||
data class UsageFile(
|
||||
val version: Int = 1,
|
||||
val days: Map<String, Map<String, Long>> = emptyMap(),
|
||||
val lastAlertAtSec: Long = 0L,
|
||||
val alertsOptOut: Boolean = false,
|
||||
)
|
||||
|
||||
private val mapper =
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var loaded = false
|
||||
private var data = UsageFile()
|
||||
|
||||
suspend fun mergeInto(
|
||||
day: Long,
|
||||
deltas: Map<String, Long>,
|
||||
) {
|
||||
if (deltas.isEmpty()) return
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
val dayKey = day.toString()
|
||||
val bucket = data.days[dayKey].orEmpty().toMutableMap()
|
||||
deltas.forEach { (key, amount) -> bucket[key] = (bucket[key] ?: 0L) + amount }
|
||||
val pruned =
|
||||
data.days
|
||||
.filterKeys { (it.toLongOrNull() ?: Long.MAX_VALUE) >= day - keepDays }
|
||||
.toMutableMap()
|
||||
pruned[dayKey] = bucket
|
||||
data = data.copy(days = pruned)
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
/** All persisted daily buckets, keyed by epoch-day. */
|
||||
suspend fun allDays(): Map<Long, Map<String, Long>> =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
data.days.mapNotNull { (k, v) -> k.toLongOrNull()?.let { it to v } }.toMap()
|
||||
}
|
||||
|
||||
suspend fun lastAlertAtSec(): Long =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
data.lastAlertAtSec
|
||||
}
|
||||
|
||||
suspend fun alertsOptOut(): Boolean =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
data.alertsOptOut
|
||||
}
|
||||
|
||||
suspend fun markAlertPrompted(atSec: Long) =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
data = data.copy(lastAlertAtSec = atSec)
|
||||
persist()
|
||||
}
|
||||
|
||||
suspend fun setAlertsOptOut(optOut: Boolean) =
|
||||
mutex.withLock {
|
||||
ensureLoaded()
|
||||
data = data.copy(alertsOptOut = optOut)
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
if (loaded) return
|
||||
data =
|
||||
try {
|
||||
if (storageFile.exists() && storageFile.length() > 0) {
|
||||
mapper.readValue<UsageFile>(storageFile)
|
||||
} else {
|
||||
UsageFile()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load resource usage from $storageFile", e)
|
||||
UsageFile()
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
storageFile.parentFile?.mkdirs()
|
||||
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
|
||||
try {
|
||||
mapper.writeValue(tmp, data)
|
||||
if (!tmp.renameTo(storageFile)) {
|
||||
if (!storageFile.delete() || !tmp.renameTo(storageFile)) {
|
||||
Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
|
||||
if (!tmp.delete()) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to persist resource usage to $storageFile", e)
|
||||
if (!tmp.delete()) {
|
||||
Log.w(TAG) { "Failed to clean up temp file $tmp" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ResourceUsageStore"
|
||||
const val FILE_NAME = "resource_usage.json"
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Integrates time-per-screen into the ledger (`screen.<Name>.ms`): which
|
||||
* parts of the app the display/CPU time actually goes to, so a report can
|
||||
* distinguish "8 h of video" from "8 h of feeds".
|
||||
*
|
||||
* PRIVACY: only the route's base name is recorded ([screenNameOf] strips
|
||||
* navigation arguments before anything reaches the ledger) — "Profile" is
|
||||
* tracked, whose profile never is. Time only accrues while the app is in the
|
||||
* foreground: the current-route × foreground combination is the segment
|
||||
* state, so backgrounding on a screen closes its segment.
|
||||
*/
|
||||
class ScreenTimeIntegrator(
|
||||
accountant: ResourceUsageAccountant,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<String>(accountant, nowMs) {
|
||||
private val currentScreen = MutableStateFlow<String?>(null)
|
||||
|
||||
/** Called from the navigation listener with an already-sanitized name (or null when unknown). */
|
||||
fun onScreen(name: String?) {
|
||||
currentScreen.value = name
|
||||
}
|
||||
|
||||
fun start(
|
||||
scope: CoroutineScope,
|
||||
isForeground: Flow<Boolean>,
|
||||
): Job {
|
||||
registerFlushHook()
|
||||
return scope.launch {
|
||||
combine(currentScreen, isForeground) { screen, fg -> if (fg) screen else null }
|
||||
.collect { transitionTo(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: String,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (elapsedMs > 0) accountant.add(UsageKeys.screenMs(state), elapsedMs)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Reduces a Navigation Compose route pattern to its bare screen name:
|
||||
* `com...routes.Route.Profile/{userId}?tab={tab}` becomes `Profile`.
|
||||
* Everything after `/` or `?` — where the arguments live — is dropped
|
||||
* BEFORE the value leaves the navigation layer.
|
||||
*/
|
||||
fun screenNameOf(route: String?): String? =
|
||||
route
|
||||
?.substringBefore('/')
|
||||
?.substringBefore('?')
|
||||
?.substringAfterLast('.')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
/**
|
||||
* Timer-free duration integrator: the elapsed time of the current state is
|
||||
* exact between transitions, so a segment is accounted only when the state
|
||||
* changes — plus on the accountant's pre-flush hook, so multi-hour stable
|
||||
* segments (a call, a backgrounded night with relays connected) still land in
|
||||
* the right day bucket without any periodic timer.
|
||||
*
|
||||
* Durations are measured with [SystemClock.elapsedRealtime] — the monotonic
|
||||
* clock — never the wall clock: an NTP/timezone correction mid-segment must
|
||||
* not fabricate or delete accounted time. (Wall time is only ever used for
|
||||
* choosing the epoch-day bucket, which happens in the accountant.)
|
||||
*
|
||||
* A `null` state means "nothing to account" (idle); subclasses decide what a
|
||||
* non-null state costs per elapsed millisecond.
|
||||
*/
|
||||
abstract class TimeSegmentIntegrator<S : Any>(
|
||||
protected val accountant: ResourceUsageAccountant,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) {
|
||||
private val lock = Any()
|
||||
private var current: S? = null
|
||||
private var segmentStartMs = 0L
|
||||
|
||||
/** Registers [closeOpenSegment] so flushes and reads see up-to-date totals. */
|
||||
fun registerFlushHook() {
|
||||
accountant.addPreFlushHook(::closeOpenSegment)
|
||||
}
|
||||
|
||||
/** Accounts the running segment up to now without changing state. */
|
||||
fun closeOpenSegment() {
|
||||
synchronized(lock) {
|
||||
val state = current ?: return
|
||||
val now = nowMs()
|
||||
account(state, now - segmentStartMs)
|
||||
segmentStartMs = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts the previous segment and starts a new one. Returns the previous
|
||||
* state so callers can detect activations (null -> non-null).
|
||||
*/
|
||||
fun transitionTo(next: S?): S? =
|
||||
synchronized(lock) {
|
||||
val now = nowMs()
|
||||
val prev = current
|
||||
prev?.let { account(it, now - segmentStartMs) }
|
||||
current = next
|
||||
segmentStartMs = now
|
||||
prev
|
||||
}
|
||||
|
||||
protected abstract fun account(
|
||||
state: S,
|
||||
elapsedMs: Long,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The simplest integrator: total time a boolean condition is active (Tor
|
||||
* running, a call in progress, GPS listening), written to [msKey] — plus an
|
||||
* optional [startsKey] counting activations, since starts are often the
|
||||
* expensive part (a Tor bootstrap, a service cold start).
|
||||
*/
|
||||
class SessionTimeIntegrator(
|
||||
accountant: ResourceUsageAccountant,
|
||||
private val msKey: String,
|
||||
private val startsKey: String? = null,
|
||||
nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : TimeSegmentIntegrator<Unit>(accountant, nowMs) {
|
||||
fun setActive(active: Boolean) {
|
||||
val prev = transitionTo(if (active) Unit else null)
|
||||
if (active && prev == null && startsKey != null) accountant.add(startsKey, 1)
|
||||
}
|
||||
|
||||
override fun account(
|
||||
state: Unit,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
if (elapsedMs > 0) accountant.add(msKey, elapsedMs)
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** Request tag carrying the ledger subsystem a request belongs to. */
|
||||
class UsageRoleTag(
|
||||
val role: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Estimates cellular-radio wake-ups caused by HTTP traffic: a new burst is
|
||||
* counted whenever bytes flow after more than [BURST_GAP_MS] of HTTP silence,
|
||||
* approximating the radio having dropped to idle in between (LTE/5G inactivity
|
||||
* timers are typically ~10s). Bytes alone don't predict battery — many small
|
||||
* scattered requests cost far more than one continuous download of the same
|
||||
* size, because every burst pays the radio's ramp + tail energy. This counter
|
||||
* is what makes that pattern visible.
|
||||
*
|
||||
* Caveat: bursts are counted from HTTP activity only. While relays are
|
||||
* connected their traffic keeps the radio awake anyway — that cost is already
|
||||
* captured by the relay connection-time counters.
|
||||
*/
|
||||
class RadioBurstEstimator(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) {
|
||||
@Volatile private var lastActivityMs = Long.MIN_VALUE
|
||||
|
||||
fun onHttpActivity() {
|
||||
val now = nowMs()
|
||||
val last = lastActivityMs
|
||||
lastActivityMs = now
|
||||
if (last == Long.MIN_VALUE || now - last > BURST_GAP_MS) {
|
||||
accountant.add(UsageKeys.radioBursts(isMobile(), isForeground()), 1)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BURST_GAP_MS = 10_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application interceptor that accounts every HTTP request into the ledger:
|
||||
* bytes up/down, request count, and active-transfer time, attributed to the
|
||||
* subsystem named by the request's [UsageRoleTag] (set by the per-role
|
||||
* clients from RoleBasedHttpClientBuilder) or to [defaultRole] for anything
|
||||
* that reaches the shared clients untagged — so no HTTP traffic can escape
|
||||
* the ledger.
|
||||
*
|
||||
* Installed FIRST on the base client (outermost), so the tagging interceptor
|
||||
* of role-wrapped clients must be inserted BEFORE it (see [HttpUsageMeter]).
|
||||
* Download bytes are counted as the app actually consumes the streamed body,
|
||||
* so cancelled loads count only what crossed to the app. Network/visibility
|
||||
* dims are sampled when bytes flow, matching what the radio actually did.
|
||||
*/
|
||||
class UsageCountingInterceptor(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val bursts: RadioBurstEstimator? = null,
|
||||
private val defaultRole: String = UsageKeys.ROLE_OTHER,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
// Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache
|
||||
// hits to 127.0.0.1) never touches the radio: counting it would inflate
|
||||
// the network numbers — and could trip the background-data alert — for
|
||||
// exactly the users who cache aggressively to SAVE data.
|
||||
if (isLoopback(request.url.host)) return chain.proceed(request)
|
||||
|
||||
val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole
|
||||
|
||||
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
|
||||
bursts?.onHttpActivity()
|
||||
|
||||
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
|
||||
if (requestBytes > 0) {
|
||||
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
|
||||
}
|
||||
|
||||
val startedAtMs = nowMs()
|
||||
val response = chain.proceed(request)
|
||||
return response
|
||||
.newBuilder()
|
||||
.body(
|
||||
CountingResponseBody(
|
||||
delegate = response.body,
|
||||
onBytes = { bytes ->
|
||||
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes)
|
||||
bursts?.onHttpActivity()
|
||||
},
|
||||
onFinished = {
|
||||
accountant.add(UsageKeys.netActiveMs(role, isMobile(), isForeground()), nowMs() - startedAtMs)
|
||||
},
|
||||
),
|
||||
).build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */
|
||||
fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]"
|
||||
}
|
||||
|
||||
private class CountingResponseBody(
|
||||
private val delegate: ResponseBody,
|
||||
private val onBytes: (Long) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
) : ResponseBody() {
|
||||
private val finished = AtomicBoolean(false)
|
||||
private val onFinishedOnce = {
|
||||
if (finished.compareAndSet(false, true)) onFinished()
|
||||
}
|
||||
|
||||
override fun contentType() = delegate.contentType()
|
||||
|
||||
override fun contentLength() = delegate.contentLength()
|
||||
|
||||
private val countedSource by lazy {
|
||||
object : ForwardingSource(delegate.source() as Source) {
|
||||
override fun read(
|
||||
sink: Buffer,
|
||||
byteCount: Long,
|
||||
): Long {
|
||||
val read = super.read(sink, byteCount)
|
||||
if (read > 0) {
|
||||
onBytes(read)
|
||||
} else if (read == -1L) {
|
||||
onFinishedOnce()
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
onFinishedOnce()
|
||||
}
|
||||
}.buffer()
|
||||
}
|
||||
|
||||
override fun source() = countedSource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands out per-subsystem OkHttp clients: the shared base client (which
|
||||
* carries the single [UsageCountingInterceptor]) wrapped with a tagging
|
||||
* interceptor inserted at position 0, OUTSIDE the counter, so the tag is
|
||||
* visible when the counter reads it. Wrapped clients are cached per
|
||||
* (role, base identity); base clients are rebuilt on proxy/network changes,
|
||||
* so the cache is cleared when it grows past a small bound.
|
||||
*/
|
||||
class HttpUsageMeter {
|
||||
private val wrapped = ConcurrentHashMap<Pair<String, OkHttpClient>, OkHttpClient>()
|
||||
|
||||
fun counted(
|
||||
role: String,
|
||||
base: OkHttpClient,
|
||||
): OkHttpClient {
|
||||
if (wrapped.size > MAX_CACHED) wrapped.clear()
|
||||
return wrapped.getOrPut(role to base) {
|
||||
base
|
||||
.newBuilder()
|
||||
.apply { interceptors().add(0, RoleTaggingInterceptor(role)) }
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private class RoleTaggingInterceptor(
|
||||
private val role: String,
|
||||
) : Interceptor {
|
||||
private val tag = UsageRoleTag(role)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response =
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.tag(UsageRoleTag::class.java, tag)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// roles (8) x live base clients (proxy on/off ~2) with headroom for
|
||||
// network-change rebuilds before the clear kicks in.
|
||||
private const val MAX_CACHED = 32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
/**
|
||||
* Counter-key grammar for the resource-usage ledger. Keys are flat strings so
|
||||
* the on-disk store is schema-free — adding a counter never needs a migration.
|
||||
*
|
||||
* Dimensions:
|
||||
* - network: `mobile` (cellular/metered) vs `wifi` (everything else)
|
||||
* - visibility: `fg` (an activity is started) vs `bg`
|
||||
* - direction: `rx` (downloaded) vs `tx` (uploaded)
|
||||
*
|
||||
* Counters are sizes, durations, and counts only — never URLs, relay names, or
|
||||
* content. See plans/2026-07-12-resource-usage-ledger.md.
|
||||
*/
|
||||
object UsageKeys {
|
||||
const val MOBILE = "mobile"
|
||||
const val WIFI = "wifi"
|
||||
const val FG = "fg"
|
||||
const val BG = "bg"
|
||||
const val RX = "rx"
|
||||
const val TX = "tx"
|
||||
|
||||
/** HTTP subsystems, matching IRoleBasedHttpClientBuilder's roles. */
|
||||
const val ROLE_IMAGE = "image"
|
||||
const val ROLE_VIDEO = "video"
|
||||
const val ROLE_UPLOADS = "uploads"
|
||||
const val ROLE_MONEY = "money"
|
||||
const val ROLE_NIP05 = "nip05"
|
||||
const val ROLE_PREVIEW = "preview"
|
||||
const val ROLE_PUSH = "push"
|
||||
|
||||
/** Catch-all for HTTP requests that reach the shared clients without a role tag. */
|
||||
const val ROLE_OTHER = "other"
|
||||
|
||||
val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER)
|
||||
|
||||
/** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */
|
||||
fun net(
|
||||
role: String,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
received: Boolean,
|
||||
): String = "net.$role.${dim(mobile, foreground)}.${if (received) RX else TX}"
|
||||
|
||||
/** `net.image.mobile.bg.reqs` — HTTP request count for a subsystem. */
|
||||
fun netReqs(
|
||||
role: String,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "net.$role.${dim(mobile, foreground)}.reqs"
|
||||
|
||||
/** `net.image.mobile.bg.activems` — wall time spent actively transferring. */
|
||||
fun netActiveMs(
|
||||
role: String,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "net.$role.${dim(mobile, foreground)}.activems"
|
||||
|
||||
/** `net.bursts.mobile.bg` — estimated radio wake-ups caused by HTTP traffic. */
|
||||
fun radioBursts(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "net.bursts.${dim(mobile, foreground)}"
|
||||
|
||||
/** `relay.msg.mobile.bg.rx` — approximate relay websocket payload bytes. */
|
||||
fun relayMsg(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
received: Boolean,
|
||||
): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}"
|
||||
|
||||
/** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */
|
||||
fun relayConnMs(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connms.${dim(mobile, foreground)}"
|
||||
|
||||
/** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */
|
||||
fun relayConnects(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connects.${dim(mobile, foreground)}"
|
||||
|
||||
/** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */
|
||||
fun relayConnectFails(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connfails.${dim(mobile, foreground)}"
|
||||
|
||||
/** `worker.scheduledPost.runs` */
|
||||
fun workerRuns(worker: String): String = "worker.$worker.runs"
|
||||
|
||||
const val WAKELOCK_NOTIF_MS = "wakelock.notif.ms"
|
||||
const val WAKELOCK_NOTIF_COUNT = "wakelock.notif.count"
|
||||
const val APP_STARTS = "app.starts"
|
||||
|
||||
/** Whole-process CPU time (user+system) — the honest aggregate of parsing, crypto, coroutines, and UI. */
|
||||
const val CPU_MS = "cpu.ms"
|
||||
|
||||
/** Time with at least one activity STARTED — the denominator that makes the other counters interpretable. */
|
||||
const val APP_FG_MS = "app.fgms"
|
||||
|
||||
/** Event signature verifications (LocalCache.justVerify). */
|
||||
const val VERIFY_COUNT = "crypto.verify.count"
|
||||
const val VERIFY_US = "crypto.verify.us"
|
||||
|
||||
/** Media (video/audio) playback time — decoder + screen + streaming all at once. */
|
||||
const val MEDIA_PLAY_MS = "media.playms"
|
||||
|
||||
/** NIP-13 proof-of-work mining: full-core CPU for as long as it runs. */
|
||||
const val POW_MS = "pow.ms"
|
||||
const val POW_SESSIONS = "pow.sessions"
|
||||
|
||||
/** In-app (Arti) Tor: circuit crypto + directory/guard keep-alives while up; each start pays a bootstrap. */
|
||||
const val TOR_MS = "tor.ms"
|
||||
const val TOR_STARTS = "tor.starts"
|
||||
|
||||
/**
|
||||
* Always-on notification relay service: uptime (the mode context for its
|
||||
* relay connections) and starts — each start beyond the first is churn
|
||||
* (watchdog alarm or auto-restart re-launching a killed service).
|
||||
*/
|
||||
const val ALWAYS_ON_MS = "service.alwayson.ms"
|
||||
const val ALWAYS_ON_STARTS = "service.alwayson.starts"
|
||||
|
||||
/** Calls and NIP-53 audio rooms: mic + Opus + a live media connection. */
|
||||
const val CALL_MS = "call.ms"
|
||||
const val CALL_SESSIONS = "call.sessions"
|
||||
const val NESTS_MS = "nests.ms"
|
||||
const val NESTS_SESSIONS = "nests.sessions"
|
||||
|
||||
/** Time spent actively listening for GPS/location updates (geohash tagging). */
|
||||
const val LOCATION_MS = "location.ms"
|
||||
|
||||
/**
|
||||
* `screen.Home.ms` — time a screen was visible while the app was in the
|
||||
* foreground. PRIVACY: only the route's base NAME is ever recorded, never
|
||||
* its navigation arguments — "Profile" is tracked, whose profile is not
|
||||
* (see ScreenTimeIntegrator.screenNameOf, which strips them).
|
||||
*/
|
||||
fun screenMs(screen: String): String = "$SCREEN_PREFIX$screen.ms"
|
||||
|
||||
const val SCREEN_PREFIX = "screen."
|
||||
|
||||
/**
|
||||
* NIP-04/44 decryptions and encryptions through account signers. Durations
|
||||
* are only metered for local-key signers (CPU cost); external/remote
|
||||
* signer waits are IPC/network, tracked by the sign/decrypt counts alone.
|
||||
*/
|
||||
const val DECRYPT_COUNT = "crypto.decrypt.count"
|
||||
const val DECRYPT_US = "crypto.decrypt.us"
|
||||
const val ENCRYPT_COUNT = "crypto.encrypt.count"
|
||||
const val ENCRYPT_US = "crypto.encrypt.us"
|
||||
|
||||
/** `sign.nip46.count` — signatures by signer kind: local key, NIP-55 (Amber IPC), NIP-46 (relay round-trip). */
|
||||
fun signs(kind: String): String = "sign.$kind.count"
|
||||
|
||||
const val SIGNER_LOCAL = "local"
|
||||
const val SIGNER_NIP46 = "nip46"
|
||||
const val SIGNER_NIP55 = "nip55"
|
||||
|
||||
/**
|
||||
* Measured battery drain (percent points while discharging), split by
|
||||
* visibility. Not app-isolated — it's the ground truth the other counters
|
||||
* get correlated against across reports.
|
||||
*/
|
||||
const val BATTERY_DRAIN_FG = "battery.drain.fg"
|
||||
const val BATTERY_DRAIN_BG = "battery.drain.bg"
|
||||
|
||||
fun dim(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}"
|
||||
|
||||
/** Sums every counter whose key matches all the given dot-delimited parts. */
|
||||
fun Map<String, Long>.sumMatching(vararg parts: String): Long {
|
||||
var total = 0L
|
||||
for ((key, value) in this) {
|
||||
val segments = key.split('.')
|
||||
if (parts.all { it in segments }) total += value
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys.sumMatching
|
||||
|
||||
/**
|
||||
* Headline metrics derived from one or more daily counter buckets. Shared by
|
||||
* the usage screen, the NIP-17 report, and the high-consumption alerts so
|
||||
* every surface agrees on the numbers.
|
||||
*/
|
||||
data class UsageSummary(
|
||||
val mobileBytesBg: Long,
|
||||
val mobileBytesFg: Long,
|
||||
val wifiBytesBg: Long,
|
||||
val wifiBytesFg: Long,
|
||||
val relayConnMsMobileBg: Long,
|
||||
val relayConnMsMobileFg: Long,
|
||||
val relayConnMsWifiBg: Long,
|
||||
val relayConnMsWifiFg: Long,
|
||||
val wakelockMs: Long,
|
||||
val wakelockCount: Long,
|
||||
val workerRuns: Long,
|
||||
val appStarts: Long,
|
||||
val relayConnects: Long,
|
||||
val relayConnectFails: Long,
|
||||
val cpuMs: Long,
|
||||
val foregroundMs: Long,
|
||||
val verifyCount: Long,
|
||||
val verifyUs: Long,
|
||||
val httpRequests: Long,
|
||||
val radioBursts: Long,
|
||||
val httpActiveMs: Long,
|
||||
val mediaPlayMs: Long,
|
||||
val powMs: Long,
|
||||
val torMs: Long,
|
||||
val torStarts: Long,
|
||||
val alwaysOnMs: Long,
|
||||
val alwaysOnStarts: Long,
|
||||
val callMs: Long,
|
||||
val nestsMs: Long,
|
||||
val locationMs: Long,
|
||||
val decryptCount: Long,
|
||||
val decryptUs: Long,
|
||||
val signNip46: Long,
|
||||
val signNip55: Long,
|
||||
val batteryDrainFg: Long,
|
||||
val batteryDrainBg: Long,
|
||||
/** total rx+tx bytes per subsystem (net roles + "relay"). */
|
||||
val bytesPerSubsystem: Map<String, Long>,
|
||||
/** cellular-only rx+tx bytes per subsystem — the scarce resource. */
|
||||
val mobileBytesPerSubsystem: Map<String, Long>,
|
||||
/** foreground time per screen NAME (arguments are never recorded). */
|
||||
val screenTimeMs: Map<String, Long>,
|
||||
/** how many day buckets this summary was built from (>= 1). */
|
||||
val dayCount: Int,
|
||||
) {
|
||||
val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg
|
||||
val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg
|
||||
val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg
|
||||
|
||||
companion object {
|
||||
fun from(
|
||||
counters: Map<String, Long>,
|
||||
dayCount: Int = 1,
|
||||
): UsageSummary {
|
||||
fun traffic(
|
||||
net: String,
|
||||
vis: String,
|
||||
) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX)
|
||||
|
||||
val subsystems = mutableMapOf<String, Long>()
|
||||
val mobileSubsystems = mutableMapOf<String, Long>()
|
||||
for (role in UsageKeys.HTTP_ROLES) {
|
||||
val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX)
|
||||
if (bytes > 0) subsystems[role] = bytes
|
||||
val mobileBytes =
|
||||
counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.RX) +
|
||||
counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.TX)
|
||||
if (mobileBytes > 0) mobileSubsystems[role] = mobileBytes
|
||||
}
|
||||
val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX)
|
||||
if (relayBytes > 0) subsystems["relay"] = relayBytes
|
||||
val mobileRelayBytes =
|
||||
counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.RX) +
|
||||
counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.TX)
|
||||
if (mobileRelayBytes > 0) mobileSubsystems["relay"] = mobileRelayBytes
|
||||
|
||||
val screens = mutableMapOf<String, Long>()
|
||||
for ((key, value) in counters) {
|
||||
if (key.startsWith(UsageKeys.SCREEN_PREFIX) && key.endsWith(".ms") && value > 0) {
|
||||
val name = key.removePrefix(UsageKeys.SCREEN_PREFIX).removeSuffix(".ms")
|
||||
if (name.isNotBlank()) screens[name] = (screens[name] ?: 0L) + value
|
||||
}
|
||||
}
|
||||
|
||||
return UsageSummary(
|
||||
mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG),
|
||||
mobileBytesFg = traffic(UsageKeys.MOBILE, UsageKeys.FG),
|
||||
wifiBytesBg = traffic(UsageKeys.WIFI, UsageKeys.BG),
|
||||
wifiBytesFg = traffic(UsageKeys.WIFI, UsageKeys.FG),
|
||||
relayConnMsMobileBg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.BG),
|
||||
relayConnMsMobileFg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.FG),
|
||||
relayConnMsWifiBg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.BG),
|
||||
relayConnMsWifiFg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.FG),
|
||||
wakelockMs = counters[UsageKeys.WAKELOCK_NOTIF_MS] ?: 0L,
|
||||
wakelockCount = counters[UsageKeys.WAKELOCK_NOTIF_COUNT] ?: 0L,
|
||||
workerRuns = counters.sumMatching("worker", "runs"),
|
||||
appStarts = counters[UsageKeys.APP_STARTS] ?: 0L,
|
||||
relayConnects = counters.sumMatching("connects"),
|
||||
relayConnectFails = counters.sumMatching("connfails"),
|
||||
cpuMs = counters[UsageKeys.CPU_MS] ?: 0L,
|
||||
foregroundMs = counters[UsageKeys.APP_FG_MS] ?: 0L,
|
||||
verifyCount = counters[UsageKeys.VERIFY_COUNT] ?: 0L,
|
||||
verifyUs = counters[UsageKeys.VERIFY_US] ?: 0L,
|
||||
httpRequests = counters.sumMatching("reqs"),
|
||||
radioBursts = counters.sumMatching("bursts"),
|
||||
httpActiveMs = counters.sumMatching("activems"),
|
||||
mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L,
|
||||
powMs = counters[UsageKeys.POW_MS] ?: 0L,
|
||||
torMs = counters[UsageKeys.TOR_MS] ?: 0L,
|
||||
torStarts = counters[UsageKeys.TOR_STARTS] ?: 0L,
|
||||
alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L,
|
||||
alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L,
|
||||
callMs = counters[UsageKeys.CALL_MS] ?: 0L,
|
||||
nestsMs = counters[UsageKeys.NESTS_MS] ?: 0L,
|
||||
locationMs = counters[UsageKeys.LOCATION_MS] ?: 0L,
|
||||
decryptCount = counters[UsageKeys.DECRYPT_COUNT] ?: 0L,
|
||||
decryptUs = counters[UsageKeys.DECRYPT_US] ?: 0L,
|
||||
signNip46 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP46)] ?: 0L,
|
||||
signNip55 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP55)] ?: 0L,
|
||||
batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L,
|
||||
batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L,
|
||||
bytesPerSubsystem = subsystems,
|
||||
mobileBytesPerSubsystem = mobileSubsystems,
|
||||
screenTimeMs = screens,
|
||||
dayCount = dayCount.coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
/** Merges several day buckets and summarizes the total. */
|
||||
fun fromDays(days: Collection<Map<String, Long>>): UsageSummary {
|
||||
val merged = mutableMapOf<String, Long>()
|
||||
days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } }
|
||||
return from(merged, dayCount = days.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -206,9 +206,28 @@ class ScheduledPostStore(
|
||||
mutableListOf()
|
||||
}
|
||||
loaded = true
|
||||
val recovered = releaseStaleClaims()
|
||||
val purged = purgeStale(nowSec())
|
||||
_flow.value = posts.toList()
|
||||
if (purged) persist()
|
||||
if (purged || recovered) persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* A PUBLISHING row found at initial disk load is a claim from a previous
|
||||
* process — the worker that held it died (crash, cancellation) before
|
||||
* markSent/markFailed/releaseClaim ran. No worker in THIS process can
|
||||
* hold a claim before the first load, so reset them to PENDING for the
|
||||
* next cycle to retry. Returns true if any row was recovered.
|
||||
*/
|
||||
private fun releaseStaleClaims(): Boolean {
|
||||
var recovered = false
|
||||
posts.forEachIndexed { idx, post ->
|
||||
if (post.status == ScheduledPostStatus.PUBLISHING) {
|
||||
posts[idx] = post.copy(status = ScheduledPostStatus.PENDING)
|
||||
recovered = true
|
||||
}
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.scheduledposts
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Keeps [ScheduledPostWorker]'s 15-minute periodic chain enqueued exactly while
|
||||
* the store holds a PENDING post. An unconditionally-scheduled periodic worker
|
||||
* wakes — and often cold-starts — the whole process every 15 minutes forever,
|
||||
* even for users who never schedule a post.
|
||||
*
|
||||
* The store is durable (JSON on disk) and the single source of truth, so
|
||||
* [onPendingWork] fires from any mutation that produces a PENDING row (add,
|
||||
* publishNow, releaseClaim) and [onNoPendingWork] when the last one drains
|
||||
* (markSent/markFailed/cancel/removeForAccount).
|
||||
*
|
||||
* PUBLISHING counts as pending work: claimDuePosts flips PENDING → PUBLISHING
|
||||
* (and emits) BEFORE the worker publishes, so gating on PENDING alone would
|
||||
* cancel the periodic worker mid-publish the moment it claims the last post —
|
||||
* stranding the post in PUBLISHING with nothing left to finish or retry it.
|
||||
*
|
||||
* [start] forces the store's initial disk load BEFORE collecting: the flow's
|
||||
* initial value is an empty list until the store is first touched, and acting
|
||||
* on that placeholder would cancel scheduled work that a pending post still
|
||||
* needs.
|
||||
*/
|
||||
class ScheduledPostWorkGate(
|
||||
private val store: ScheduledPostStore,
|
||||
private val scope: CoroutineScope,
|
||||
private val onPendingWork: () -> Unit,
|
||||
private val onNoPendingWork: () -> Unit,
|
||||
) {
|
||||
fun start(): Job =
|
||||
scope.launch {
|
||||
store.list()
|
||||
store.flow
|
||||
.map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING || it.status == ScheduledPostStatus.PUBLISHING } }
|
||||
.distinctUntilChanged()
|
||||
.collect { hasPending ->
|
||||
if (hasPending) {
|
||||
onPendingWork()
|
||||
} else {
|
||||
onNoPendingWork()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -42,10 +43,15 @@ import java.util.concurrent.TimeUnit
|
||||
/**
|
||||
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
|
||||
*
|
||||
* - schedule(context): periodic, every 15 min (WorkManager minimum).
|
||||
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
|
||||
* due while the device was off or while WorkManager
|
||||
* was deferred by Doze.
|
||||
* - schedule(context): periodic, every 15 min (WorkManager minimum). Only
|
||||
* enqueued while the store holds a PENDING post — the
|
||||
* store observer in AppModules schedules it when a
|
||||
* pending post appears and cancels it when the last
|
||||
* one drains, so the worker never wakes the process
|
||||
* with nothing to publish.
|
||||
* - scheduleCatchUp(context): one-time, to flush posts that came due while the
|
||||
* device was off or while WorkManager was deferred
|
||||
* by Doze.
|
||||
*/
|
||||
class ScheduledPostWorker(
|
||||
appContext: Context,
|
||||
@@ -107,6 +113,16 @@ class ScheduledPostWorker(
|
||||
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the 15-minute periodic chain (keeps any catch-up run). Called by the
|
||||
* store observer in AppModules when the last PENDING post drains, so the
|
||||
* worker doesn't keep waking the process with nothing to publish.
|
||||
*/
|
||||
fun cancelPeriodic(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.d(TAG) { "cancelPeriodic(): cancelled periodic worker" }
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
|
||||
@@ -117,6 +133,7 @@ class ScheduledPostWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" }
|
||||
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("scheduledPost"), 1) }
|
||||
|
||||
return try {
|
||||
val appModules = Amethyst.instance
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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"
|
||||
|
||||
+21
-5
@@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -80,10 +83,14 @@ fun DisappearingScaffold(
|
||||
) {
|
||||
val state = rememberDisappearingBarState()
|
||||
|
||||
// Large screens (rail / permanent drawer) pin the chrome: bars never slide away on
|
||||
// scroll, and the immersive status-bar hiding stays off.
|
||||
val canHideBars = allowBarHide && !LocalScreenLayout.current.isLargeScreen
|
||||
|
||||
// Hold the latest values in state so the NSC's captured lambda stays fresh across
|
||||
// recompositions without rebuilding the NSC itself.
|
||||
val latestIsActive by rememberUpdatedState(isActive)
|
||||
val latestAllowBarHide by rememberUpdatedState(allowBarHide)
|
||||
val latestAllowBarHide by rememberUpdatedState(canHideBars)
|
||||
val latestAccountViewModel by rememberUpdatedState(accountViewModel)
|
||||
|
||||
val connection =
|
||||
@@ -100,24 +107,33 @@ fun DisappearingScaffold(
|
||||
}
|
||||
|
||||
// Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars.
|
||||
if (allowBarHide) {
|
||||
if (canHideBars) {
|
||||
ResetBarsOnResume(state)
|
||||
ImmersiveStatusBarEffect(state)
|
||||
}
|
||||
|
||||
// If the bars were scrolled away when hiding got disabled (e.g. the window grew to a
|
||||
// large tier mid-scroll), nothing above can bring them back — the nested-scroll
|
||||
// connection and the resume reset are gone. Snap them visible here instead of
|
||||
// leaving the chrome stranded off-screen.
|
||||
LaunchedEffect(canHideBars, state) {
|
||||
if (!canHideBars) state.resetToVisible()
|
||||
}
|
||||
|
||||
// When bars are pinned, skip attaching the nested-scroll connection entirely.
|
||||
// The outer Surface provides the Material container color + onBackground as
|
||||
// LocalContentColor, matching M3 Scaffold's behaviour (without it, default text
|
||||
// color falls back to Color.Black and is invisible on the dark theme).
|
||||
val baseModifier =
|
||||
if (allowBarHide) {
|
||||
if (canHideBars) {
|
||||
Modifier.imePadding().nestedScroll(connection)
|
||||
} else {
|
||||
Modifier.imePadding()
|
||||
}
|
||||
val rootModifier =
|
||||
baseModifier
|
||||
.let { if (topBar == null) it.statusBarsPadding() else it }
|
||||
// systemBars (not just statusBars) so a desktop window's caption bar is respected too.
|
||||
.let { if (topBar == null) it.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) else it }
|
||||
.let { if (bottomBar == null) it.navigationBarsPadding() else it }
|
||||
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.layouts
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
|
||||
/** How the app shell presents its top-level navigation for the current window size. */
|
||||
enum class NavigationStyle {
|
||||
/** Compact windows (phones): bottom navigation bar + modal drawer. */
|
||||
BOTTOM_BAR,
|
||||
|
||||
/**
|
||||
* Medium windows (portrait tablets, unfolded foldables): a left navigation rail
|
||||
* replaces the bottom bar; the drawer stays modal behind the rail's avatar button.
|
||||
*/
|
||||
NAV_RAIL,
|
||||
|
||||
/** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */
|
||||
PERMANENT_DRAWER,
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell layout decisions for the current window, published once per window size change
|
||||
* through [LocalScreenLayout] so every screen, bar and panel agrees on the same tier.
|
||||
*/
|
||||
@Immutable
|
||||
data class ScreenLayoutSpec(
|
||||
val navigationStyle: NavigationStyle,
|
||||
val showsNotificationPanel: Boolean,
|
||||
) {
|
||||
/**
|
||||
* True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin
|
||||
* the top/bottom chrome (no disappearing bars on scroll).
|
||||
*/
|
||||
val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR
|
||||
|
||||
companion object {
|
||||
val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone }
|
||||
|
||||
/**
|
||||
* Minimum window width for the docked notification panel: the permanent drawer
|
||||
* ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth])
|
||||
* only coexist comfortably from a landscape-tablet-sized window up.
|
||||
*/
|
||||
private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200
|
||||
|
||||
val PermanentDrawerWidth = 300.dp
|
||||
|
||||
val NotificationPanelWidth = 360.dp
|
||||
|
||||
/**
|
||||
* Maximum width of a screen's content column inside a wide center pane. Every NavHost
|
||||
* destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects),
|
||||
* so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading
|
||||
* column instead of stretching across the pane. Screens that genuinely need the full pane
|
||||
* (Messages' two-pane split, the embedded browser surfaces) opt out at registration.
|
||||
*/
|
||||
val FeedContentMaxWidth = 600.dp
|
||||
|
||||
/**
|
||||
* Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme
|
||||
* background so the gutters match the screens' own surfaces; on Compact windows the cap is
|
||||
* wider than the pane and this is a visual no-op.
|
||||
*/
|
||||
@Composable
|
||||
fun CappedScreenContent(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.widthIn(max = FeedContentMaxWidth)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
fun rememberScreenLayoutSpec(): ScreenLayoutSpec {
|
||||
val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass
|
||||
val windowWidthDp = LocalConfiguration.current.screenWidthDp
|
||||
return remember(widthSizeClass, windowWidthDp) {
|
||||
val style =
|
||||
when (widthSizeClass) {
|
||||
WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER
|
||||
WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL
|
||||
else -> NavigationStyle.BOTTOM_BAR
|
||||
}
|
||||
ScreenLayoutSpec(
|
||||
navigationStyle = style,
|
||||
showsNotificationPanel =
|
||||
style == NavigationStyle.PERMANENT_DRAWER &&
|
||||
windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -42,12 +44,16 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
|
||||
@@ -55,6 +61,10 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.LocalTabReselectCoordinator
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinator
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
@@ -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,
|
||||
|
||||
+106
-19
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
@@ -33,6 +35,7 @@ import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent
|
||||
|
||||
// Per-entry hint stamped by Nav.navBottomBar marking that the entry was
|
||||
// reached via a bottom-nav tab. Used in two places:
|
||||
@@ -44,41 +47,99 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot"
|
||||
|
||||
fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) == true
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
|
||||
/**
|
||||
* The shell's current layout tier, mirrored for the transition specs below. Transition
|
||||
* lambdas run when a navigation starts — outside composition — so they can't read
|
||||
* LocalScreenLayout; AppNavigation mirrors the spec here instead.
|
||||
*
|
||||
* One navigation grammar, tier-scaled motion: phones keep full-width slides (a pushed
|
||||
* screen physically stacks on top), while large screens use short shared-axis moves —
|
||||
* content there swaps inside a persistent shell, and a full-pane slide from the right
|
||||
* reads as disconnected when the click came from the docked drawer on the left.
|
||||
*/
|
||||
object NavTransitionTier {
|
||||
@Volatile
|
||||
var isLargeScreen: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless
|
||||
* it opted out with `capWidth = false`. Every builder below routes through this, so all
|
||||
* destinations — top bars included — share the centered column on large screens by default.
|
||||
*/
|
||||
@Composable
|
||||
fun MaybeCappedScreen(
|
||||
capWidth: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (capWidth) {
|
||||
CappedScreenContent(content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Stock fade-transition destination, capped to the reading-column width on wide panes. */
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
|
||||
composable<T> { entry ->
|
||||
CappedScreenContent { content(entry) }
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
composable<T>(
|
||||
enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd },
|
||||
exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut },
|
||||
popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn },
|
||||
popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd },
|
||||
content = content,
|
||||
enterTransition = { if (targetState.isBottomNavRoot()) null else enterFromEnd() },
|
||||
exitTransition = { if (targetState.isBottomNavRoot()) null else exitBehind() },
|
||||
popEnterTransition = { if (initialState.isBottomNavRoot()) null else popEnterFromBehind() },
|
||||
popExitTransition = { if (initialState.isBottomNavRoot()) null else popExitToEnd() },
|
||||
content = { entry ->
|
||||
MaybeCappedScreen(capWidth) { content(entry) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
|
||||
composableFromEnd<T> {
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
|
||||
) {
|
||||
composableFromEnd<T>(capWidth) {
|
||||
content(it.toRoute<T>())
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
composable<T>(
|
||||
enterTransition = { slideInVerticallyFromBottom },
|
||||
exitTransition = { scaleOut },
|
||||
popEnterTransition = { scaleIn },
|
||||
popExitTransition = { slideOutVerticallyToBottom },
|
||||
content = content,
|
||||
enterTransition = { enterFromBottom() },
|
||||
exitTransition = { exitBehind() },
|
||||
popEnterTransition = { popEnterFromBehind() },
|
||||
popExitTransition = { popExitToBottom() },
|
||||
content = { entry ->
|
||||
MaybeCappedScreen(capWidth) { content(entry) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
|
||||
composableFromBottom<T> {
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
|
||||
) {
|
||||
composableFromBottom<T>(capWidth) {
|
||||
content(it.toRoute())
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
|
||||
composable<T> {
|
||||
content(it.toRoute())
|
||||
inline fun <reified T : Any> NavGraphBuilder.composableArgs(
|
||||
capWidth: Boolean = true,
|
||||
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
|
||||
) {
|
||||
composable<T> { entry ->
|
||||
MaybeCappedScreen(capWidth) { content(entry.toRoute()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,3 +151,29 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta
|
||||
|
||||
val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f)
|
||||
val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f)
|
||||
|
||||
/** Fraction of the pane a shared-axis move travels on large screens — a nudge, not a fly-in. */
|
||||
private const val SHARED_AXIS_FRACTION = 10
|
||||
|
||||
val sharedAxisEnterFromEnd = slideInHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween())
|
||||
val sharedAxisExitToEnd = slideOutHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween())
|
||||
val sharedAxisEnterFromBottom = slideInVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween())
|
||||
val sharedAxisExitToBottom = slideOutVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween())
|
||||
|
||||
// The outgoing/incoming screen *behind* a push: on phones the pushed screen covers it, so a
|
||||
// slight scale is enough; on large screens the incoming screen fades, so the one behind must
|
||||
// fade too or both stay visible mid-transition.
|
||||
val fadeScaleOut = scaleOut + fadeOut(animationSpec = tween())
|
||||
val fadeScaleIn = scaleIn + fadeIn(animationSpec = tween())
|
||||
|
||||
fun enterFromEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromEnd else slideInHorizontallyFromEnd
|
||||
|
||||
fun popExitToEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToEnd else slideOutHorizontallyToEnd
|
||||
|
||||
fun enterFromBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromBottom else slideInVerticallyFromBottom
|
||||
|
||||
fun popExitToBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToBottom else slideOutVerticallyToBottom
|
||||
|
||||
fun exitBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleOut else scaleOut
|
||||
|
||||
fun popEnterFromBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleIn else scaleIn
|
||||
|
||||
+60
-27
@@ -36,8 +36,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -49,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -73,6 +76,22 @@ fun AppBottomBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: (Route) -> Unit,
|
||||
) {
|
||||
// Publish this screen's re-tap behavior even when the bar renders nothing: on large
|
||||
// screens the navigation rail routes reselect taps back through the coordinator so the
|
||||
// same per-screen scroll-to-top/refresh logic runs.
|
||||
val coordinator = LocalTabReselectCoordinator.current
|
||||
val latestRoute by rememberUpdatedState(selectedRoute)
|
||||
val latestOnClick by rememberUpdatedState(onClick)
|
||||
DisposableEffect(coordinator) {
|
||||
val handler: (Route) -> Unit = { latestOnClick(it) }
|
||||
coordinator.register({ latestRoute }, handler)
|
||||
onDispose { coordinator.unregister(handler) }
|
||||
}
|
||||
|
||||
// Large screens replace the bottom bar with the navigation rail (Medium) or the
|
||||
// permanently docked drawer (Expanded).
|
||||
if (LocalScreenLayout.current.isLargeScreen) return
|
||||
|
||||
// Hide the bar on entries that aren't a tab root (drawer or in-app
|
||||
// pushes). Mirrors the back-arrow rule in canPop().
|
||||
if (nav.canPop()) return
|
||||
@@ -101,6 +120,43 @@ fun AppBottomBar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the icon model for a pinned favorite: a web favorite's captured favicon (else the
|
||||
* generic globe), an nsite/napplet's verified manifest icon bundled in its own content (the
|
||||
* iframe sandbox rules out live capture; else the grid glyph). Shared by the bottom bar and
|
||||
* the navigation rail.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? =
|
||||
when (fav) {
|
||||
is FavoriteApp.WebApp -> {
|
||||
// Captured favicons, keyed so the icon appears once the site's capture lands.
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
remember(fav, iconKeys) {
|
||||
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
|
||||
}
|
||||
}
|
||||
|
||||
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
|
||||
}
|
||||
|
||||
/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */
|
||||
@Composable
|
||||
internal fun FavoriteEntryIcon(
|
||||
fav: FavoriteApp,
|
||||
selected: Boolean,
|
||||
iconModel: Any?,
|
||||
) {
|
||||
Box(Size27Modifier, contentAlignment = Alignment.Center) {
|
||||
FavoriteAppIcon(
|
||||
app = fav,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
|
||||
modifier = Size25Modifier,
|
||||
iconModel = iconModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBottomMenu(
|
||||
items: List<BottomBarEntry>,
|
||||
@@ -112,9 +168,6 @@ private fun RenderBottomMenu(
|
||||
// Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan.
|
||||
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
|
||||
|
||||
// Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe.
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -147,17 +200,7 @@ private fun RenderBottomMenu(
|
||||
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
|
||||
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
|
||||
}
|
||||
// A web favorite uses its captured favicon; an nsite/napplet uses the verified
|
||||
// icon blob bundled in its own content (the iframe sandbox rules out live capture).
|
||||
val iconModel =
|
||||
when (fav) {
|
||||
is FavoriteApp.WebApp ->
|
||||
remember(fav, iconKeys) {
|
||||
OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor)
|
||||
}
|
||||
is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate)
|
||||
}
|
||||
FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav)
|
||||
FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,18 +218,7 @@ private fun RowScope.FavoriteNavItem(
|
||||
) {
|
||||
NavigationBarItem(
|
||||
alwaysShowLabel = false,
|
||||
icon = {
|
||||
Box(Size27Modifier, contentAlignment = Alignment.Center) {
|
||||
// A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else
|
||||
// the grid glyph).
|
||||
FavoriteAppIcon(
|
||||
app = fav,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
|
||||
modifier = Size25Modifier,
|
||||
iconModel = iconModel,
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = { FavoriteEntryIcon(fav, selected, iconModel) },
|
||||
// No label — favorite tabs match the built-in items, which show icon only.
|
||||
selected = selected,
|
||||
onClick = { nav(destination) },
|
||||
@@ -216,8 +248,9 @@ private fun RowScope.HasNewItemsIcon(
|
||||
)
|
||||
}
|
||||
|
||||
/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */
|
||||
@Composable
|
||||
private fun NotifiableIcon(
|
||||
internal fun NotifiableIcon(
|
||||
selected: Boolean,
|
||||
def: NavBarItemDef,
|
||||
destination: Route,
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Medium-width windows: a left rail that carries the same user-configured destinations as the
|
||||
* phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the
|
||||
* user's customization and new-item dots carry over. The header avatar opens the modal drawer,
|
||||
* mirroring the avatar button in the phone top bars. Re-tapping the selected item routes
|
||||
* through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler.
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavigationRail(
|
||||
nav: Nav,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
|
||||
|
||||
val reselectCoordinator = LocalTabReselectCoordinator.current
|
||||
|
||||
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
NavigationRail(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
header = {
|
||||
// Same affordance as the phone top bars: the account avatar opens the drawer.
|
||||
LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
items.forEach { entry ->
|
||||
when (entry) {
|
||||
is BottomBarEntry.BuiltIn -> {
|
||||
val def = NavBarCatalog[entry.item] ?: return@forEach
|
||||
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
|
||||
val selected = currentDestination?.hasRoute(destination::class) == true
|
||||
NavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (selected) {
|
||||
reselectCoordinator.reselect(destination)
|
||||
} else {
|
||||
nav.navBottomBar(destination)
|
||||
}
|
||||
},
|
||||
icon = { NotifiableIcon(selected, def, destination, accountViewModel) },
|
||||
)
|
||||
}
|
||||
|
||||
is BottomBarEntry.Favorite -> {
|
||||
val fav = favoritesById[entry.favoriteId] ?: return@forEach
|
||||
val destination =
|
||||
when (fav) {
|
||||
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
|
||||
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
|
||||
}
|
||||
// Favorites carry arguments (url / coordinate), so class matching alone
|
||||
// would light up every pinned app of the same kind; compare the full route.
|
||||
val selected =
|
||||
remember(navBackStackEntry, destination) {
|
||||
when (destination) {
|
||||
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination
|
||||
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
NavigationRailItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (selected) {
|
||||
reselectCoordinator.reselect(destination)
|
||||
} else {
|
||||
nav.navBottomBar(destination)
|
||||
}
|
||||
},
|
||||
icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
val FABPaddingFromBottom = 30.dp
|
||||
|
||||
@Composable
|
||||
fun Modifier.fabBottomBarPadding(nav: INav): Modifier = if (nav.canPop()) padding(bottom = FABPaddingFromBottom) else this
|
||||
fun Modifier.fabBottomBarPadding(nav: INav): Modifier =
|
||||
if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) {
|
||||
// canPop entries hide the bar on phones; large screens never render it at all.
|
||||
padding(bottom = FABPaddingFromBottom)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that places [content] in a [Box] with [fabBottomBarPadding] applied.
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
|
||||
/**
|
||||
* Bridges the shell-level [AppNavigationRail] to the per-screen re-tap behaviors that live in
|
||||
* each screen's [AppBottomBar] onClick lambda (scroll-to-top, feed refresh, ...).
|
||||
*
|
||||
* On phones the bottom bar invokes the screen's lambda directly. On large screens the bar
|
||||
* renders nothing, but it still registers the screen's lambda here; when the user taps the
|
||||
* rail item that is already selected, the rail routes the tap back through [reselect] so the
|
||||
* exact same per-screen logic runs.
|
||||
*/
|
||||
@Stable
|
||||
class TabReselectCoordinator {
|
||||
private var currentRoute: (() -> Route?)? = null
|
||||
private var currentHandler: ((Route) -> Unit)? = null
|
||||
|
||||
fun register(
|
||||
route: () -> Route?,
|
||||
handler: (Route) -> Unit,
|
||||
) {
|
||||
currentRoute = route
|
||||
currentHandler = handler
|
||||
}
|
||||
|
||||
/** Unregisters only if [handler] is still the active one, so a newly composed screen's
|
||||
* registration is not torn down by the outgoing screen's dispose during a transition. */
|
||||
fun unregister(handler: (Route) -> Unit) {
|
||||
if (currentHandler === handler) {
|
||||
currentHandler = null
|
||||
currentRoute = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Invokes the active tab root's handler if it owns [route]. Returns true if handled. */
|
||||
fun reselect(route: Route): Boolean {
|
||||
val handler = currentHandler ?: return false
|
||||
if (currentRoute?.invoke() != route) return false
|
||||
handler(route)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
val LocalTabReselectCoordinator = staticCompositionLocalOf { TabReselectCoordinator() }
|
||||
+90
-45
@@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -56,6 +57,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
|
||||
@@ -147,59 +150,93 @@ fun DrawerContent(
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val onClickUser = {
|
||||
nav.nav(routeFor(accountViewModel.userProfile()))
|
||||
nav.closeDrawer()
|
||||
}
|
||||
|
||||
ModalDrawerSheet(
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.background,
|
||||
drawerTonalElevation = 0.dp,
|
||||
) {
|
||||
DrawerContentBody(nav, openSheet, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded windows: the same drawer content, permanently docked on the left of the shell
|
||||
* instead of sliding in as a modal sheet.
|
||||
*/
|
||||
@Composable
|
||||
fun PermanentDrawerContent(
|
||||
nav: INav,
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.width(PermanentDrawerWidth).fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
Modifier.windowInsetsPadding(
|
||||
WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start),
|
||||
),
|
||||
) {
|
||||
ProfileContent(
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
accountViewModel,
|
||||
onClickUser,
|
||||
)
|
||||
|
||||
Column(drawerSpacing) {
|
||||
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav)
|
||||
}
|
||||
|
||||
FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
ListContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BottomContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
DrawerContentBody(nav, openSheet, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerContentBody(
|
||||
nav: INav,
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val onClickUser = {
|
||||
nav.nav(routeFor(accountViewModel.userProfile()))
|
||||
nav.closeDrawer()
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
ProfileContent(
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
accountViewModel,
|
||||
onClickUser,
|
||||
)
|
||||
|
||||
Column(drawerSpacing) {
|
||||
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav)
|
||||
}
|
||||
|
||||
FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
ListContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
BottomContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
@@ -237,14 +274,14 @@ fun ProfileContentTemplate(
|
||||
AsyncImage(
|
||||
model = profileBanner,
|
||||
contentDescription = stringRes(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = R.drawable.profile_banner,
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
}
|
||||
@@ -392,8 +429,10 @@ fun StatusEditBar(
|
||||
|
||||
val currentStatus = remember { mutableStateOf(savedStatus ?: "") }
|
||||
|
||||
// In the docked drawer the DrawerState never opens (it stays Closed while the drawer
|
||||
// is always on screen), so the modal close-cancels-editing behavior must not apply there.
|
||||
LaunchedEffect(nav.drawerState.isClosed) {
|
||||
if (nav.drawerState.isClosed) {
|
||||
if (!nav.isDrawerDocked && nav.drawerState.isClosed) {
|
||||
focusManager.clearFocus(true)
|
||||
onDone()
|
||||
} else {
|
||||
@@ -427,6 +466,10 @@ fun StatusEditBar(
|
||||
}
|
||||
|
||||
focusManager.clearFocus(true)
|
||||
// Collapse back to the read-only bar: in the docked drawer no
|
||||
// drawer-close will ever do it, and in the modal drawer this beats
|
||||
// staying in edit mode until the drawer closes.
|
||||
onDone()
|
||||
},
|
||||
),
|
||||
singleLine = true,
|
||||
@@ -442,12 +485,14 @@ fun StatusEditBar(
|
||||
accountViewModel.updateStatus(address, currentStatus.value)
|
||||
}
|
||||
focusManager.clearFocus(true)
|
||||
onDone()
|
||||
}
|
||||
} else {
|
||||
if (address != null) {
|
||||
UserStatusDeleteButton {
|
||||
accountViewModel.deleteStatus(address)
|
||||
focusManager.clearFocus(true)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user