mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-09-14 00:55:08 +00:00
Compare commits
167
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18b7f770f | ||
|
|
826fc826db | ||
|
|
6cf09f0d75 | ||
|
|
9ea7c36cf9 | ||
|
|
69532badee | ||
|
|
a1f9cc96f1 | ||
|
|
a0a55af174 | ||
|
|
defbdfbb28 | ||
|
|
af49bcc396 | ||
|
|
cbd0a4a174 | ||
|
|
34c60fada1 | ||
|
|
0030432e20 | ||
|
|
36819b1011 | ||
|
|
985b4c2ea3 | ||
|
|
c25517c2d6 | ||
|
|
790458f9a0 | ||
|
|
6f71eb0c4c | ||
|
|
04d506e81e | ||
|
|
b10be95a6e | ||
|
|
514f4b26f0 | ||
|
|
91d4d66461 | ||
|
|
8a43d707e6 | ||
|
|
de3ce56bcc | ||
|
|
2aa4a43337 | ||
|
|
2415565ebf | ||
|
|
a370b1d8c5 | ||
|
|
e38981fd1d | ||
|
|
7ff8e3b5ac | ||
|
|
e4d288a9c0 | ||
|
|
f4c452a761 | ||
|
|
12a1b571ba | ||
|
|
6f97faf167 | ||
|
|
7665acb878 | ||
|
|
f150696a26 | ||
|
|
3eff5f2d87 | ||
|
|
bfcff43868 | ||
|
|
ef22469410 | ||
|
|
be7f099b7f | ||
|
|
5ea4d6770e | ||
|
|
8d457de93e | ||
|
|
72324011a4 | ||
|
|
4d578006db | ||
|
|
42652e6b36 | ||
|
|
c6916d49d1 | ||
|
|
f4bf36030b | ||
|
|
3b4ac12eb1 | ||
|
|
0d9b2d8d29 | ||
|
|
2f220656f8 | ||
|
|
b71f26917a | ||
|
|
16b4bc9197 | ||
|
|
c857bfe064 | ||
|
|
f40b80eb9c | ||
|
|
029c40ebb4 | ||
|
|
cd5441be44 | ||
|
|
16ef96279f | ||
|
|
81e39fe29b | ||
|
|
340fbee132 | ||
|
|
1da2c32df3 | ||
|
|
cdfc7ddb17 | ||
|
|
42c297104c | ||
|
|
dfddf35e40 | ||
|
|
2ffc5ff49d | ||
|
|
9fae1e526e | ||
|
|
e5e6076039 | ||
|
|
163b272ae8 | ||
|
|
b06093e9a2 | ||
|
|
1d420dc406 | ||
|
|
ccdcf433e1 | ||
|
|
ec27db70cc | ||
|
|
8293cbfb7f | ||
|
|
2735d6612b | ||
|
|
9bd85d0cbc | ||
|
|
4f3e9fd1cd | ||
|
|
b29519e4e6 | ||
|
|
319348de9a | ||
|
|
97919fd460 | ||
|
|
d44e1ff1d3 | ||
|
|
866897022f | ||
|
|
c8bc0f6378 | ||
|
|
dd98d9da9d | ||
|
|
519b76c708 | ||
|
|
f1b36ec4a5 | ||
|
|
d651ad3b1e | ||
|
|
8059dd7898 | ||
|
|
09cddc59f1 | ||
|
|
965d55779c | ||
|
|
1e26fb8d5a | ||
|
|
7ffb5a9556 | ||
|
|
551ded1cf9 | ||
|
|
3a057f47b7 | ||
|
|
040c4c9f01 | ||
|
|
18b0dafec6 | ||
|
|
e7bcb88d30 | ||
|
|
b266f1c403 | ||
|
|
4261124402 | ||
|
|
e0e2e427c4 | ||
|
|
ef4e7075be | ||
|
|
51d3485b53 | ||
|
|
1b569eec7f | ||
|
|
3e459ec5ad | ||
|
|
60d84b8d75 | ||
|
|
deee5e5cde | ||
|
|
d06b83bd53 | ||
|
|
8114f054d4 | ||
|
|
c291026d5a | ||
|
|
e7b7211625 | ||
|
|
a3623ceffd | ||
|
|
00ccef7277 | ||
|
|
b935f4c99c | ||
|
|
a1edb597bc | ||
|
|
8d79145d2a | ||
|
|
9aa4a9536c | ||
|
|
b9ff65290b | ||
|
|
e1df0f0c45 | ||
|
|
22c170c510 | ||
|
|
24a8540ad9 | ||
|
|
fb0d8bd857 | ||
|
|
baae40e5fc | ||
|
|
f11bdb8e49 | ||
|
|
f1c461dcfa | ||
|
|
bdb2990846 | ||
|
|
2f1e1d546c | ||
|
|
cc13664816 | ||
|
|
de9a41ddb9 | ||
|
|
cb5ddd7a77 | ||
|
|
33a64ce73a | ||
|
|
17c9357b2f | ||
|
|
ed2b3ef8d7 | ||
|
|
d28afe0677 | ||
|
|
6becc6efbe | ||
|
|
ff1ecde496 | ||
|
|
2587294c55 | ||
|
|
9eed37c5f4 | ||
|
|
3a53e2b965 | ||
|
|
98f09f29c0 | ||
|
|
f5f8f605ec | ||
|
|
111f3392b4 | ||
|
|
58f144f0d1 | ||
|
|
f4c130d7fe | ||
|
|
2ff7b7f199 | ||
|
|
1c25bcb8d3 | ||
|
|
541b6116d7 | ||
|
|
3e383e77d7 | ||
|
|
c16e3f3c69 | ||
|
|
28e8af2564 | ||
|
|
4ef3d12adf | ||
|
|
e184db69c7 | ||
|
|
7f0e1f2f90 | ||
|
|
6ca19eab90 | ||
|
|
36a74ae10c | ||
|
|
78acc61318 | ||
|
|
1190b55dbf | ||
|
|
81d5add807 | ||
|
|
e4adae05de | ||
|
|
c3f7bf52e9 | ||
|
|
daa6022adc | ||
|
|
18d28cf8ed | ||
|
|
9c8c11a0d7 | ||
|
|
16aae86791 | ||
|
|
6614b0bd60 | ||
|
|
9dc209cdfe | ||
|
|
a82035c601 | ||
|
|
a5e2aae960 | ||
|
|
41fff6b9b3 | ||
|
|
416cd32eb3 | ||
|
|
94976dbe74 | ||
|
|
7594d826a8 |
@@ -1,16 +1,17 @@
|
||||
---
|
||||
name: find-non-lambda-logs
|
||||
description: Use when auditing or migrating Log calls — flags both interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene) and catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces)
|
||||
description: Use when auditing or migrating Log calls — flags interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene), catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces), and files still importing android.util.Log (no lambda overload, bypasses Log.minLevel)
|
||||
---
|
||||
|
||||
# Find Non-Lambda Log Calls
|
||||
|
||||
## Overview
|
||||
|
||||
Two related logging hygiene issues:
|
||||
Three related logging hygiene issues:
|
||||
|
||||
1. **Lambda overload missing.** `Log.d/i/w/e` calls that use string interpolation without the lambda overload waste string allocation when the log level is filtered out in release builds.
|
||||
2. **Throwable dropped in catch blocks.** `Log.w/e` calls inside `catch (e: ...)` blocks that interpolate `${e.message}` but don't pass `e` lose the stack trace, and log nothing useful when `e.message` is null (NPE, IOException with no message, etc.).
|
||||
3. **Still on `android.util.Log`.** Files importing the platform logger bypass `Log.minLevel` and the `LogSink`, and have no lambda overload — so neither fix above can be applied to them. Step 0 finds these; the last section migrates them.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -39,6 +40,54 @@ Log.d("Tag", "Initialization complete")
|
||||
|
||||
**Important:** Tags can be string literals (`"Tag"`) or variables (`tag`, `LOG_TAG`). Run both patterns for each step.
|
||||
|
||||
**The throwable-name alternation, used by Steps 2 and 3** — define it once and reuse it, rather than writing a shorter list in one step and a longer one in another:
|
||||
|
||||
```bash
|
||||
THROWABLE='(e|t|it|ex|err|error|throwable|cause|tr)'
|
||||
```
|
||||
|
||||
**Filter the noise before counting**, or the totals mislead: drop `/build/`, `/androidTest/` and `/src/test/` (release filtering doesn't apply to tests), and drop lines whose first non-space character is `//` or `*` — commented-out calls and KDoc examples both match these patterns. A `grep -vE ':[0-9]+: *(//|\*)'` handles the last one.
|
||||
|
||||
### Step 0: Find files still on `android.util.Log` (run this first)
|
||||
|
||||
**Two patterns — the fully-qualified one alone is a false negative.** Almost nobody writes `android.util.Log.w(...)` at the call site; they `import android.util.Log` and then write `Log.w(...)`, which is indistinguishable from the wrapper by call shape. The import is the reliable signal:
|
||||
|
||||
```bash
|
||||
# the form that actually occurs
|
||||
grep -rln --include='*.kt' '^import android\.util\.Log$' . | grep -v '/build/' | grep -v PlatformLog
|
||||
|
||||
# the rare fully-qualified call
|
||||
grep -rnE --include='*.kt' 'android\.util\.Log\.(d|i|w|e|v)\(' . | grep -v '/build/' | grep -v PlatformLog
|
||||
```
|
||||
|
||||
On 2026-08-28 the fully-qualified pattern reported **0** while the import pattern found **16 production files** (9 in `nappletHost`, the rest in amethyst's `favorites/` and `napplet/`). Exclude `PlatformLog.android.kt`, which is the wrapper implementation and must call `android.util.Log`.
|
||||
|
||||
These bypass the `Log.minLevel` filter and the `LogSink` indirection entirely, and — the practical consequence for this skill — **they have no lambda overload**, so Steps 1–3 cannot be applied to them until they are migrated. Subtract these files from the Step 1–3 candidate lists, or migrate them first (see the last section).
|
||||
|
||||
### Step 0b: The patterns are line-anchored — sweep multi-line calls separately
|
||||
|
||||
Every `pattern:` in Steps 1–3 matches a call written on one line. A call formatted as
|
||||
|
||||
```kotlin
|
||||
Log.d(
|
||||
TAG,
|
||||
"WASTE ${url.url} dials=${r.tentatives.get()} " +
|
||||
"fail=[${r.failures.entries.joinToString { … }}]",
|
||||
)
|
||||
```
|
||||
|
||||
is **structurally invisible** to them. That biases the audit towards short calls and away from expensive ones — the multi-line form is what long, heavily interpolated messages look like, and those are exactly the ones worth deferring. A 2026-08-28 sweep converted three one-line banner calls in `BootRelayDiagnostics.kt` while walking past two `Log.d` calls in `forEach` loops immediately below them, running 25 and 20 iterations per census with nested `joinToString` in each — strictly the larger cost, three lines away.
|
||||
|
||||
Catch them with the open-paren-at-EOL form, then read each hit:
|
||||
|
||||
```bash
|
||||
grep -rnE --include='*.kt' 'Log\.[diwe]\($' . | grep -v '/build/'
|
||||
# or, to see the whole call:
|
||||
rg -U --multiline --type kotlin 'Log\.[diwe]\(\n[^)]*\$\{'
|
||||
```
|
||||
|
||||
**Prioritise call sites inside loops over one-liners.** A `Log.d` in a 25-iteration `forEach` discards 25 built strings per pass; a one-line banner discards one.
|
||||
|
||||
### Step 1: Find interpolated Log.d/Log.i (highest priority — filtered in release)
|
||||
|
||||
```
|
||||
@@ -61,7 +110,14 @@ pattern: Log\.(w|e)\(\w+,\s*"[^"]*\$
|
||||
type: kotlin
|
||||
```
|
||||
|
||||
Then **manually exclude** lines where a throwable is passed as third argument (ending with `, e)`, `, throwable)`, etc.). Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
|
||||
Then **manually exclude** lines where a throwable is passed as third argument. Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
|
||||
|
||||
**`it` is the name you will miss.** `Result.onFailure { ... }` is the dominant shape in this repo, so most correct calls end `, it)`, not `, e)`. Excluding only `e`/`throwable` inflates the result badly — a 2026-08-28 pass reported 23 hits where the real number was 8, because 14 of them were `.onFailure { Log.w(TAG, "...", it) }` and already correct. Also note the throwable is not always last on the line (`}.onFailure { Log.w(...) }.getOrDefault(false)`), so anchoring the exclusion to `$` misses them:
|
||||
|
||||
```bash
|
||||
grep -rnE --include='*.kt' 'Log\.(w|e)\([^,]+,\s*"[^"]*\$' . \
|
||||
| grep -vE ",\s*$THROWABLE\)" # note: no $ anchor, and `it` included
|
||||
```
|
||||
|
||||
### Step 3: Find catch-block Log.w/e that drop the throwable
|
||||
|
||||
@@ -70,23 +126,14 @@ Among the Step 2 hits, the calls that interpolate `${e.message}` (or `${t.messag
|
||||
Quick filter:
|
||||
|
||||
```
|
||||
pattern: Log\.(w|e)\([^)]*\$\{(e|t|throwable|cause)\.message\}[^)]*\)$
|
||||
pattern: Log\.(w|e)\([^)]*\$\{(e|t|it|ex|err|throwable|cause)\.message\}
|
||||
type: kotlin
|
||||
```
|
||||
|
||||
Then for each hit, open the file and confirm the line is **inside a `catch (e: ...)` block** and **does not pass `e` (or the matching name) as a third argument**. False positives: extension functions / helpers that accept an `e: SomeError` parameter and forward it elsewhere.
|
||||
Note this deliberately omits the `\)$` anchor and includes `it` — same reasons as Step 2. Then for each hit, open the file and confirm the line is **inside a `catch (e: ...)` block** and **does not pass `e` (or the matching name) as a third argument**. False positives: extension functions / helpers that accept an `e: SomeError` parameter and forward it elsewhere.
|
||||
|
||||
Both Step 2 and Step 3 may flag the same line — handle Step 3 first (different fix), then apply Step 2 to whatever remains.
|
||||
|
||||
### Step 4: Verify no android.util.Log leakage
|
||||
|
||||
```
|
||||
pattern: android\.util\.Log\.(d|i|w|e|v)\(
|
||||
type: kotlin
|
||||
```
|
||||
|
||||
These bypass the `Log.minLevel` filter entirely. Exclude `PlatformLog.android.kt` which is the wrapper implementation.
|
||||
|
||||
## Fix Patterns
|
||||
|
||||
### Lambda overload (Step 1 + Step 2)
|
||||
@@ -106,7 +153,7 @@ Switch to `(tag, msg, throwable)` — the lambda overload does **not** accept a
|
||||
```kotlin
|
||||
// Before — stack trace lost, prints "...failed: null" if e.message is null
|
||||
try { groupManager.clearAllState() } catch (e: Exception) {
|
||||
Log.w("MarmotManager") { "clearAllState failed: ${e.message}" }
|
||||
Log.w("MarmotManager", "clearAllState failed: ${e.message}")
|
||||
}
|
||||
|
||||
// After — full stack trace logged
|
||||
@@ -120,6 +167,25 @@ Trade-off: the message string is allocated eagerly even when warn is filtered, b
|
||||
## Do NOT Convert
|
||||
|
||||
- **To lambda:** calls passing a `Throwable` parameter — the lambda overload `(tag) { message }` has no throwable parameter.
|
||||
- **To lambda: any call in a file that imports `android.util.Log`.** The platform `Log` has no lambda overload, so the conversion fails to compile with `None of the following candidates is applicable`. Either migrate the file first (below) or leave the call alone. (Hit on 2026-08-28: three edits in two files had to be reverted.)
|
||||
- Static string calls with no `$` interpolation — no allocation benefit.
|
||||
- Commented-out log calls.
|
||||
- Informational/intentional log of `e.message` *outside* a catch block (rare; usually means the exception was already handled and only the message is meaningful).
|
||||
|
||||
## Migrating a file off `android.util.Log`
|
||||
|
||||
This is what unlocks Steps 1–3 for the files Step 0 finds. It is a behaviour change, so check it rather than assuming — but in this repo the check has come out safe, and here is the reasoning to redo:
|
||||
|
||||
1. **Which levels does the file use?** `grep -hoE 'Log\.[a-zA-Z]+' <files> | sort | uniq -c`. The wrapper has `d/i/w/e` only — **no `v`**, and no `getStackTraceString`. A `Log.v` call has no direct equivalent and needs a decision, not a rename.
|
||||
2. **Would the gate drop them?** `LogLevel { DEBUG, INFO, WARN, ERROR }`, the gate is `minLevel <= <level>`, and `Amethyst.DEFAULT_LOG_LEVEL` is INFO in debug, **WARN in release** (deliberately — so relay-protocol refusals stay visible in the field). The wrapper's own default is `DEBUG`. So `Log.w` and `Log.e` survive in every build type and in every process, including before `Amethyst.init` runs — which matters for `:napplet`. `Log.d`/`Log.i` **would** go silent in release; those need a conscious call.
|
||||
3. **Does the output move?** No. `PlatformLogSink` on Android delegates to `android.util.Log`, so lines land in logcat unchanged.
|
||||
4. **Can the module see quartz?** `nappletHost` already has `implementation(project(":quartz"))`. Check before assuming.
|
||||
|
||||
Then: swap `import android.util.Log` → `import com.vitorpamplona.quartz.utils.Log`, run `./gradlew spotlessApply` (import order changes), and convert only the interpolated no-throwable calls to the lambda form. Calls that already pass a throwable keep the eager three-arg shape — the wrapper's `w(tag, msg, throwable)` matches exactly, so only the import moves.
|
||||
|
||||
**Verify the throwables survived**, since a careless rewrite can drop the third argument silently:
|
||||
|
||||
```bash
|
||||
grep -hoE 'Log\.[diwe]\([^)]*,\s*(e|it)\)' <files> | wc -l # compare before/after
|
||||
```
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
|
||||
expression. **Update this file in the same PR as any change to the searchable set or to an
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-25.
|
||||
|
||||
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
kind 30063 has a collision — see the footnote). File paths are under
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
|
||||
|
||||
@@ -100,6 +100,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
|
||||
| 30315 | StatusEvent | nip38UserStatus | `content` |
|
||||
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
|
||||
| 30392 | UserTrustedListEvent | experimental/trustedLists/users | inherited `TrustedListEvent`: `title() ?: ""` — the label only; `metric`/`d` are machine ids and `content` is a JSON echo of the membership |
|
||||
| 30393 | EventTrustedListEvent | experimental/trustedLists/events | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30394 | AddressableTrustedListEvent | experimental/trustedLists/addressables | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30395 | ExternalIdTrustedListEvent | experimental/trustedLists/externalIds | inherited `TrustedListEvent`: `title() ?: ""` |
|
||||
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
|
||||
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
|
||||
@@ -150,6 +154,7 @@ declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `Event
|
||||
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
|
||||
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
|
||||
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
|
||||
| `TrustedListEvent` | `title() ?: ""` | 30392, 30393, 30394, 30395 |
|
||||
|
||||
## How to regenerate / verify this table
|
||||
|
||||
|
||||
@@ -94,6 +94,39 @@ jobs:
|
||||
$CMD
|
||||
fi
|
||||
|
||||
# This job runs five test suites (:quartz, :commons, :nestsClient, :cli,
|
||||
# :desktopApp) but, unlike test-geode / test-quartz-ios /
|
||||
# test-and-build-android, published nothing when one of them failed. The
|
||||
# console line names the failing test and the exception class and stops
|
||||
# there, so the message is lost with the runner. That is how the
|
||||
# NostrClientNegentropySyncTest failure in run 10540 became
|
||||
# undiagnosable: NegentropySyncException carries a `detail` naming which
|
||||
# branch fired (connect timeout / idle silence / NEG-ERR / disconnect),
|
||||
# and nobody could read it. Same action and pin as the Android job below.
|
||||
- name: Desktop Test Report
|
||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2
|
||||
if: always()
|
||||
with:
|
||||
report_paths: '**/build/test-results/**/TEST-*.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
fail_on_failure: true
|
||||
|
||||
# The HTML reports carry the full stack traces and stdout/stderr the
|
||||
# annotations truncate. Named per-OS because the three matrix legs upload
|
||||
# into the same run and artifact names must be unique.
|
||||
- name: Upload Desktop Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: Desktop Test Reports (${{ matrix.os }})
|
||||
path: |
|
||||
quartz/build/reports/tests
|
||||
commons/build/reports/tests
|
||||
nestsClient/build/reports/tests
|
||||
cli/build/reports/tests
|
||||
desktopApp/build/reports/tests
|
||||
|
||||
# jpackage pins libicu to the build host's version (libicu74 on
|
||||
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu
|
||||
# releases can install the uploaded artifact.
|
||||
|
||||
@@ -3,11 +3,11 @@ name: Bump Homebrew Formula (geode relay)
|
||||
# Sibling of bump-homebrew-formula.yml (the amy CLI). Same mechanism, different
|
||||
# artifact:
|
||||
# - bump-homebrew-formula.yml -> Formula `amy` (the headless CLI)
|
||||
# - this workflow -> Formula `geode` (the standalone relay)
|
||||
# - this workflow -> Formula `geode-relay` (the standalone relay)
|
||||
#
|
||||
# After a stable release, download the published `geode-<version>-jvm.tar.gz`
|
||||
# bundle, compute its sha256, and open a PR that syncs
|
||||
# `geode/packaging/homebrew/geode.rb`'s url + sha256 to that release. Keeping the
|
||||
# `geode/packaging/homebrew/geode-relay.rb`'s url + sha256 to that release. Keeping the
|
||||
# in-repo reference formula accurate makes the eventual homebrew-core submission a
|
||||
# copy-paste.
|
||||
#
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
- name: Update reference formula
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FORMULA=geode/packaging/homebrew/geode.rb
|
||||
FORMULA=geode/packaging/homebrew/geode-relay.rb
|
||||
URL="${{ steps.asset.outputs.url }}"
|
||||
SHA="${{ steps.asset.outputs.sha256 }}"
|
||||
# Rewrite the two indented lines in the formula block. Anchoring on the
|
||||
@@ -119,11 +119,11 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: main
|
||||
branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }}
|
||||
add-paths: geode/packaging/homebrew/geode.rb
|
||||
add-paths: geode/packaging/homebrew/geode-relay.rb
|
||||
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
|
||||
title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
|
||||
body: |
|
||||
Auto-synced `geode/packaging/homebrew/geode.rb` to the
|
||||
Auto-synced `geode/packaging/homebrew/geode-relay.rb` to the
|
||||
`${{ steps.rel.outputs.tag }}` release:
|
||||
|
||||
- `url` -> `${{ steps.asset.outputs.url }}`
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
``,
|
||||
`Recovery options:`,
|
||||
`1. Re-run the workflow once the underlying issue is fixed`,
|
||||
`2. Manually update \`geode/packaging/homebrew/geode.rb\` (url + sha256) from the release asset`,
|
||||
`2. Manually update \`geode/packaging/homebrew/geode-relay.rb\` (url + sha256) from the release asset`,
|
||||
`3. Check the release actually published \`geode-${tag.replace(/^v/, '')}-jvm.tar.gz\``
|
||||
].join('\n'),
|
||||
labels: ['release-ops', 'bug']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Sync Homebrew Cask Reference
|
||||
|
||||
# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml
|
||||
# (geode). Same mechanism, third artifact:
|
||||
# (geode-relay). Same mechanism, third artifact:
|
||||
# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
|
||||
#
|
||||
# What it does: after a stable release, download the published macOS DMG, assert
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Sync Winget Manifest Reference
|
||||
|
||||
# Fourth sibling of the three Homebrew sync workflows, same shape:
|
||||
# bump-homebrew-formula.yml -> Formula `amy`
|
||||
# bump-homebrew-geode-formula.yml -> Formula `geode`
|
||||
# bump-homebrew-geode-formula.yml -> Formula `geode-relay`
|
||||
# bump-homebrew.yml -> Cask `amethyst-nostr`
|
||||
# this workflow -> Winget `VitorPamplona.Amethyst`
|
||||
#
|
||||
|
||||
@@ -186,6 +186,13 @@ device. PRs that introduce any of them will be sent back.
|
||||
body only runs when the log level is enabled. Plain
|
||||
`Log.d("msg $x")` allocates the formatted string on every call,
|
||||
including in feed and scroll hot paths.
|
||||
- **Never `import android.util.Log`.** The platform logger bypasses
|
||||
`Log.minLevel` and the `LogSink`, and it has no lambda overload, so
|
||||
the rule above cannot be applied at those call sites. The one
|
||||
legitimate user is `PlatformLog.android.kt`, which implements the
|
||||
wrapper. A call that must pass a throwable uses the eager three-arg
|
||||
form `Log.w(tag, "msg", e)` — the lambda overload takes no throwable,
|
||||
and dropping it to keep the lambda loses the stack trace.
|
||||
- **Strip diagnostic `Log.d` calls before commit.** Logs added
|
||||
during on-device debugging — even lambda-form ones — must be
|
||||
removed from the production diff. They survive R8 stripping only
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
# Upstream issue draft — Compose `WindowInsets.ime` permanently wedges after a cancelled IME animation
|
||||
|
||||
Target: Google IssueTracker → **component 612128 (Jetpack Compose)**.
|
||||
The library-specific component the docs link to (856989, from the "Create a new issue" button on
|
||||
the Compose Foundation release notes) does not grant public Create Issues permission, so this is
|
||||
filed one level up with a routing request at the top of the body.
|
||||
|
||||
Status: **FILED as https://issuetracker.google.com/issues/552500419 (b/552500419)** on 2026-08-25,
|
||||
against component 612128 with a routing request. Remaining open item: the AOSP commit that introduced `runningAnimation`
|
||||
between 1.3.0 and 1.4.0-alpha01 has not been identified (android.googlesource.com returned 403
|
||||
to automated fetch). Adding the commit link before filing would help triage.
|
||||
|
||||
---
|
||||
|
||||
## Title
|
||||
|
||||
`WindowInsets.ime` stops updating permanently when an IME animation is cancelled without `onEnd` (regression in 1.4.0, still present in 1.13.0-alpha01)
|
||||
|
||||
## Routing
|
||||
|
||||
Please reassign to the owner of **`androidx.compose.foundation` / `foundation-layout`**
|
||||
(WindowInsets). Filing here because component 856989 — the target of the "Create a new issue"
|
||||
button on the [Compose Foundation release notes](https://developer.android.com/jetpack/androidx/releases/compose-foundation)
|
||||
— does not grant Create Issues permission to external accounts. That documented path being
|
||||
unusable by the public is arguably a separate docs bug worth fixing.
|
||||
|
||||
## Affected versions
|
||||
|
||||
* **Broken:** `androidx.compose.foundation:foundation-layout` **1.4.0 → 1.12.0 (current stable) and 1.13.0-alpha01**
|
||||
* **Not broken:** 1.3.0 and earlier
|
||||
* Verified by inspecting published `-sources.jar` for 1.2.0, 1.3.0, 1.4.0-alpha01…rc01, 1.4.0,
|
||||
1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0, 1.10.0, 1.11.0, 1.12.0, 1.13.0-alpha01.
|
||||
`runningAnimation` and its guard are absent in 1.3.0 and present from 1.4.0-alpha01 onward,
|
||||
textually unchanged since.
|
||||
* Reproduced on a Pixel 8, Android 17 (API 37). The API-30-only self-heal (below) means API 31+
|
||||
has no recovery path at all.
|
||||
|
||||
## Summary
|
||||
|
||||
If a `WindowInsetsAnimation` is prepared and started but never ended — what a cancelled IME
|
||||
animation looks like — `InsetsListener.runningAnimation` stays `true` forever. From that point
|
||||
`onApplyWindowInsets` matches neither of its two branches, so `composeInsets.update()` is never
|
||||
called again and **`WindowInsets.ime` is frozen for the remaining life of the window**.
|
||||
|
||||
Every `Modifier.imePadding()` in the app then holds a keyboard-height gap open with no keyboard
|
||||
on screen, permanently. `WindowInsets.imeAnimationTarget` keeps reporting correctly, because
|
||||
`updateImeAnimationTarget()` is called outside the guard — that asymmetry is the only reason a
|
||||
workaround is possible at all.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Deterministic instrumented test, ~3s, no gestures and no timing dependence. It drives Compose's
|
||||
own listener through the cancelled-animation sequence using **public** interfaces
|
||||
(`WindowInsetsAnimationCompat.Callback`, `OnApplyWindowInsetsListener`); reflection is used only
|
||||
to obtain the listener instance for the view. Inside the androidx codebase `InsetsListener` is
|
||||
directly accessible, so `listenerFor()` can be deleted and the rest of the test used verbatim.
|
||||
|
||||
```
|
||||
FAIL aCancelledImeAnimationMustNotWedgeTheAnimatedInset
|
||||
expected:<0> but was:<957>
|
||||
PASS theAnimationTargetSurvivesTheWedge
|
||||
```
|
||||
|
||||
The second test is expected to pass and is included on purpose: it pins the asymmetry between the
|
||||
two readings, and would catch a "fix" that broke `imeAnimationTarget` instead.
|
||||
|
||||
The full test source is attached below.
|
||||
|
||||
## Root cause
|
||||
|
||||
`compose/foundation/foundation-layout/src/androidMain/kotlin/androidx/compose/foundation/layout/WindowInsets.android.kt`
|
||||
|
||||
```kotlin
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
prepared = true
|
||||
runningAnimation = true // set here…
|
||||
}
|
||||
|
||||
override fun onStart(animation, bounds): BoundsCompat {
|
||||
prepared = false // …prepared cleared, runningAnimation left set
|
||||
return super.onStart(animation, bounds)
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
prepared = false
|
||||
runningAnimation = false // …cleared ONLY here
|
||||
…
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
savedInsets = insets
|
||||
composeInsets.updateImeAnimationTarget(insets) // unconditional — stays correct
|
||||
if (prepared) {
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
|
||||
view.post(this) // self-heal, API 30 ONLY
|
||||
}
|
||||
} else if (!runningAnimation) {
|
||||
composeInsets.updateImeAnimationSource(insets)
|
||||
composeInsets.update(insets) // the animated inset — never reached when wedged
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
After a cancelled animation: `prepared == false` (cleared by `onStart`) and
|
||||
`runningAnimation == true` (never cleared, because `onEnd` never came). Neither branch runs.
|
||||
`composeInsets.update()` is dead.
|
||||
|
||||
### Why the existing self-heal does not help
|
||||
|
||||
`run()` exists precisely to handle a cancelled animation, but:
|
||||
|
||||
1. it is gated to `Build.VERSION.SDK_INT == Build.VERSION_CODES.R` (API 30 only), and
|
||||
2. it is posted only from the `if (prepared)` branch, and returns early unless `prepared` is still
|
||||
`true` — which `onStart` has already cleared.
|
||||
|
||||
So it covers "cancelled between `onPrepare` and `onStart`, on API 30". It does not cover
|
||||
"cancelled after `onStart`", on any API level.
|
||||
|
||||
### Why applications cannot recover
|
||||
|
||||
The only reset is `insetsListener.resetState()`, called from `WindowInsetsHolder.incrementAccessors()`
|
||||
when `accessCount` transitions `0 → 1`. `accessCount` is driven by `WindowInsetsHolder.current()`'s
|
||||
`DisposableEffect`, so it only reaches 0 when *every* insets consumer leaves composition
|
||||
simultaneously.
|
||||
|
||||
In a single-Activity app whose shell (scaffold / bottom bar / drawer) always reads insets, that
|
||||
never happens — the holder is created once and lives for the whole process. There is no public API
|
||||
to force the reset. `WindowInsetsHolder` is `internal`.
|
||||
|
||||
Multi-Activity apps mask this: a new Activity means a new `View`, a new holder, and fresh state, so
|
||||
the wedge dies with the Activity and reads as a transient glitch.
|
||||
|
||||
### Regression point
|
||||
|
||||
1.3.0's `onApplyWindowInsets` had no such gate and could not wedge:
|
||||
|
||||
```kotlin
|
||||
override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
if (prepared) {
|
||||
savedInsets = insets
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) view.post(this)
|
||||
return insets
|
||||
}
|
||||
composeInsets.update(insets) // unconditional once onStart cleared `prepared`
|
||||
return …
|
||||
}
|
||||
```
|
||||
|
||||
1.4.0 introduced `runningAnimation` and the `else if (!runningAnimation)` guard. Its own comment
|
||||
states the intent:
|
||||
|
||||
> `// If an animation is running, rely on onProgress() to update the insets`
|
||||
> `// On APIs less than 30 where the IME animation is backported, this avoids reporting`
|
||||
> `// the final insets for a frame while the animation is running.`
|
||||
|
||||
i.e. a **one-frame** cosmetic flash on **API < 30** was fixed by making the update path conditional
|
||||
on a flag that only `onEnd` clears — trading a single wrong frame on old devices for permanent
|
||||
state corruption on all of them. The compensating recovery was never widened past `SDK_INT == R`.
|
||||
|
||||
## Real-world impact
|
||||
|
||||
Observed in a production Compose app (Amethyst, a Nostr client; single-Activity, `NavHost`,
|
||||
77 `imePadding()` sites):
|
||||
|
||||
* On a Pixel 8 / Android 17, after ordinary manual use, `WindowInsets.ime` pinned at 957px while
|
||||
the window reported `ime frame=[0,0][0,0]` — keyboard gone — and stayed pinned for 85+ seconds
|
||||
until the process was restarted. Nothing in the app cleared it.
|
||||
* Instrumented `WindowInsets.ime` vs `WindowInsets.imeAnimationTarget` across the failure:
|
||||
|
||||
```
|
||||
17:12:58.803 animated=882 target=957 ← healthy open, 13 intermediate frames
|
||||
17:12:58.902 animated=957 target=957
|
||||
17:13:00.584 animated=957 target=0 ← dismissed; animated frozen
|
||||
17:13:02.430 animated=0 target=957 ← reopened; snaps, no intermediate frames
|
||||
17:13:03.479 animated=957 target=0 ← dismissed; frozen permanently
|
||||
```
|
||||
|
||||
Note the loss of per-frame updates after the wedge: healthy transitions carry ~13 intermediate
|
||||
values over ~264ms; post-wedge transitions carry none.
|
||||
* Because the app never navigates away from its single Activity and its shell always reads insets,
|
||||
`accessCount` never returns to 0, so the wedge is permanent for the session. Sessions in this app
|
||||
routinely run for days.
|
||||
|
||||
The trigger for the underlying cancellation was not isolated — it is infrequent and required
|
||||
extended manual use to hit. The defect being reported is not the cancellation itself but that
|
||||
Compose enters a state it can never leave when one occurs. The attached test reproduces that state
|
||||
directly and deterministically.
|
||||
|
||||
## Suggested fixes
|
||||
|
||||
Roughly in order of how targeted they are:
|
||||
|
||||
1. **Generalise the existing self-heal.** Post the `run()` reconciliation on all API levels, and
|
||||
arm it after `onStart` as well as after `onPrepare`, so that an `onApplyWindowInsets` that
|
||||
arrives with no intervening `onProgress` clears `runningAnimation` and applies `savedInsets`.
|
||||
This preserves the API<30 one-frame behaviour the guard was added for, while bounding the
|
||||
failure to a frame rather than forever.
|
||||
2. **Reconcile on dispatch.** In `onApplyWindowInsets`, if `runningAnimation` is set but no
|
||||
`onProgress` has been received since `onStart`, treat the animation as finished and update.
|
||||
3. **Expose a reset.** A public way to reach `WindowInsetsHolder.resetState()` (or a documented
|
||||
condition under which it runs) would at least let applications self-heal. Today they cannot,
|
||||
short of reflection into an `internal` class — which R8 can rename or strip in exactly the
|
||||
release builds where this occurs.
|
||||
|
||||
(1) or (2) is preferable: (3) only makes the bug survivable rather than fixing it.
|
||||
|
||||
## Environment
|
||||
|
||||
* `androidx.compose.foundation:foundation-layout` 1.12.0 (Compose BOM 2026.08.00)
|
||||
* Pixel 8 (`shiba`), Android 17 / API 37, gesture navigation, Gboard, 120Hz
|
||||
* Also inspected: 1.13.0-alpha01 — identical listener code
|
||||
|
||||
---
|
||||
|
||||
## Attachment — the failing test
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.ui.insets
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Upstream regression test for androidx.compose.foundation:foundation-layout.
|
||||
*
|
||||
* A `WindowInsetsAnimation` that is prepared and started but never ended — which is what a
|
||||
* cancelled IME animation looks like — leaves `InsetsListener.runningAnimation` set forever.
|
||||
* `onApplyWindowInsets` then matches neither of its two branches, so `composeInsets.update()`
|
||||
* is never called again and `WindowInsets.ime` is dead for the life of the window.
|
||||
*
|
||||
* Introduced in 1.4.0 (absent in 1.3.0, where `onApplyWindowInsets` updated unconditionally
|
||||
* once `onStart` had cleared `prepared`). Still present in 1.12.0 and 1.13.0-alpha01. The
|
||||
* compensating self-heal (`view.post(this)` -> `run()`) is scoped to `SDK_INT == R`, so on
|
||||
* API 31+ nothing clears the flag; `WindowInsetsHolder.resetState()` only runs when the
|
||||
* holder's accessCount transitions 0 -> 1, which never happens in an app whose shell always
|
||||
* reads insets.
|
||||
*
|
||||
* [aCancelledImeAnimationMustNotWedgeTheAnimatedInset] FAILS on every version from 1.4.0 on.
|
||||
* [theAnimationTargetSurvivesTheWedge] documents the asymmetry that makes a workaround possible
|
||||
* and is expected to PASS — `updateImeAnimationTarget` is called outside the guard.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
private fun imeInsets(bottom: Int): WindowInsetsCompat =
|
||||
WindowInsetsCompat
|
||||
.Builder()
|
||||
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom))
|
||||
.setVisible(WindowInsetsCompat.Type.ime(), bottom > 0)
|
||||
.build()
|
||||
|
||||
/** Compose's own listener for this view. Private class, but both interfaces it exposes are public. */
|
||||
private fun listenerFor(view: View): Any {
|
||||
val holderClass = Class.forName("androidx.compose.foundation.layout.WindowInsetsHolder")
|
||||
val companion =
|
||||
holderClass.getDeclaredField("Companion").run {
|
||||
isAccessible = true
|
||||
get(null)
|
||||
}
|
||||
val holder =
|
||||
companion.javaClass
|
||||
.getDeclaredMethod("getOrCreateFor", View::class.java)
|
||||
.run {
|
||||
isAccessible = true
|
||||
invoke(companion, view)
|
||||
}
|
||||
return holderClass.getDeclaredField("insetsListener").run {
|
||||
isAccessible = true
|
||||
get(holder)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun anim() = WindowInsetsAnimationCompat(WindowInsetsCompat.Type.ime(), LinearInterpolator(), 250L)
|
||||
|
||||
private fun bounds() =
|
||||
WindowInsetsAnimationCompat.BoundsCompat(
|
||||
Insets.NONE,
|
||||
Insets.of(0, 0, 0, keyboardHeight),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
fun aCancelledImeAnimationMustNotWedgeTheAnimatedInset() {
|
||||
var animated by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
animated = WindowInsets.ime.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
// Baseline: with no animation in flight the inset tracks normally.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals("baseline: the inset must follow a plain dispatch", keyboardHeight, animated)
|
||||
|
||||
// A cancelled animation: prepared and started, but onEnd never arrives.
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
// The keyboard is gone and the window says so. The animated inset must follow.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"WindowInsets.ime must still track the window after an animation was cancelled " +
|
||||
"without onEnd; it is instead frozen at the keyboard height forever",
|
||||
0,
|
||||
animated,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
fun theAnimationTargetSurvivesTheWedge() {
|
||||
var target by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
target = WindowInsets.imeAnimationTarget.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals(keyboardHeight, target)
|
||||
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"updateImeAnimationTarget is called outside the guard, so this reading stays truthful",
|
||||
0,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# Defaults stand in for the user's relay lists only while we have no event
|
||||
|
||||
**Status:** proposal — not implemented
|
||||
**Goal:** first-login startup on a Tor-enabled install
|
||||
**Related:** `fix/tor-bootstrap-stall-and-ondemand`, `[[fresh-install-routes-everything-via-tor]]`
|
||||
|
||||
## The rule
|
||||
|
||||
Three states, currently collapsed into two:
|
||||
|
||||
| we have | effective list | today |
|
||||
|---|---|---|
|
||||
| **no event** for the user | app defaults | defaults ✅ |
|
||||
| event, **empty** list | **empty** — the user chose nothing | defaults ❌ |
|
||||
| event with relays | those relays | those relays ✅ |
|
||||
|
||||
Everything below follows from separating "we don't know" from "we know, and it's nothing".
|
||||
|
||||
## Why the first login is slow
|
||||
|
||||
On a fresh install **100% of relay traffic is Tor-routed by construction**.
|
||||
`TorRelayState.trustedRelays` is empty, so `TorRelayEvaluation.useTor()` falls through to
|
||||
`newRelaysViaTor` (**default true**) for every URL — and the kind-10002 that would populate it can
|
||||
only be fetched over Tor. Measured (SM-T220, same account, same ~app+8-10s login, fresh install
|
||||
each; the Tor-OFF arm sets the pref, force-stops, then starts the timed run so Arti never boots):
|
||||
|
||||
| @20s census | Tor ON | Tor OFF |
|
||||
|---|---|---|
|
||||
| feed on screen | login+18s | **login+11s** |
|
||||
| relays opened | 18/40 | **32/41** |
|
||||
| relays serving events | 9 | **22** |
|
||||
| events ingested | 2,830 | **6,134 / 7,641** |
|
||||
|
||||
≈7s of first paint and half the relay coverage.
|
||||
|
||||
## Finding 1 — every `WithBackup` helper keys on emptiness, not absence
|
||||
|
||||
This is a pre-existing bug against the rule above, and it must be fixed first because the whole
|
||||
feature depends on the distinction being real.
|
||||
|
||||
```kotlin
|
||||
// AdvertisedRelayListEvent
|
||||
fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) // [] when none
|
||||
fun readRelaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseReadNorm).ifEmpty { null } // null!
|
||||
fun writeRelaysNorm()= tags.mapNotNull(AdvertisedRelayInfo::parseWriteNorm).ifEmpty { null } // null!
|
||||
```
|
||||
|
||||
| helper | fallback fires when | correct |
|
||||
|---|---|---|
|
||||
| `normalizeNIP65AllRelayListWithBackup` | event absent only | ✅ (by accident — `relays()` has no `ifEmpty`) |
|
||||
| `normalizeNIP65Read/WriteRelayListWithBackup` | event absent **or list empty** | ❌ |
|
||||
| `normalizeIndexerRelayListWithBackup` | `?.ifEmpty { null } ?: DefaultIndexerRelayList` | ❌ |
|
||||
| `normalizeSearchRelayListWithBackup` | `?.ifEmpty { null } ?: DefaultSearchRelayList` | ❌ |
|
||||
|
||||
Consequence today: **a user who publishes a kind-10002 with only write relays gets
|
||||
`Constants.bootstrapInbox` silently substituted as their inbox list.** Same for a deliberately empty
|
||||
search or indexer list. The app overrides an explicit choice.
|
||||
|
||||
The mirror problem sinks the obvious implementation: the `NoDefaults` variants return `emptySet()`
|
||||
for *both* "no event" and "empty event", so `trustedRelays.isEmpty()` cannot be used as the
|
||||
"do we have data yet" signal.
|
||||
|
||||
**Fix:** make presence explicit, and never infer it from emptiness.
|
||||
|
||||
```kotlin
|
||||
// absent -> defaults; present -> whatever it says, including nothing
|
||||
fun readRelayList(note: Note): Set<NormalizedRelayUrl> =
|
||||
nip65Event(note)?.let { it.readRelaysNorm()?.toSet() ?: emptySet() } ?: Constants.bootstrapInbox
|
||||
```
|
||||
|
||||
Same shape for write/all, and drop the `?.ifEmpty { null }` from the indexer and search helpers.
|
||||
Worth doing on its own merits even if the rest of this plan is dropped.
|
||||
|
||||
**This removes the need for any window or timeout.** The fallback becomes a pure function of "do we
|
||||
have the event", so it ends the instant one arrives — even an empty one. No per-account bookkeeping,
|
||||
no 30s backstop, no race to close.
|
||||
|
||||
## Finding 2 — do NOT put defaults into `TrustedRelayListsState`
|
||||
|
||||
Tempting (it already merges all nine lists) but wrong: `account.trustedRelays.flow` feeds
|
||||
`Account.kt:454`
|
||||
|
||||
```kotlin
|
||||
isInMyRelayList = { relayUrl -> ... it in trustedRelays.flow.value }
|
||||
```
|
||||
|
||||
which feeds `RelayAuthPermissionLedger` -> `RelayAuthResolver` -> **the NIP-42 AUTH decision**.
|
||||
Adding defaults there would make the app **auto-AUTH to the six hardcoded bootstrap relays as if
|
||||
they were the user's own** — signing a challenge with the user's key and revealing the pubkey — at
|
||||
exactly the moment we are also going clearnet. That converts a modest timing leak into a signed
|
||||
identity assertion. See `[[relay-auth-always-was-gated]]` and `[[inbox-wine-notify-auth-billing]]`
|
||||
for why AUTH is the sensitive edge.
|
||||
|
||||
(The `saveTrustedRelayList(trustedRelays + relay)` write path in `RelayGroupChannelListScreen:449`
|
||||
is **not** a hazard — it reads `account.trustedRelayList` (the NIP-51 list), not the merged
|
||||
`trustedRelays`. Checked.)
|
||||
|
||||
**Instead:** add a separate, purpose-named flow consumed only by Tor evaluation, e.g.
|
||||
`Account.relaysAssumedWhileUnknown` — the union of the with-defaults views, non-empty only while the
|
||||
corresponding events are absent. `AccountsTorStateConnector` feeds it into a new
|
||||
`TorRelayState.assumedRelays`. Nothing else reads it.
|
||||
|
||||
## Where the check goes in `useTor()`
|
||||
|
||||
```
|
||||
torType == OFF -> false
|
||||
isLocalHost -> false
|
||||
isOverlayNetwork -> false
|
||||
isOnion -> onionRelaysViaTor
|
||||
in moneyOpRelayList -> moneyOperationsViaTor
|
||||
in dmRelayList -> dmRelaysViaTor
|
||||
in trustedRelayList -> trustedRelaysViaTor
|
||||
in assumedRelayList -> trustedRelaysViaTor <-- new, immediately above the fallback
|
||||
else -> newRelaysViaTor
|
||||
```
|
||||
|
||||
Landing immediately above the fallback means **.onion, money-operation and DM relays keep their own
|
||||
policy for free** — the change can only ever affect URLs that would have been treated as "new".
|
||||
|
||||
Resolve to `trustedRelaysViaTor`, **not** a hardcoded `false`:
|
||||
|
||||
- default user (`false`) -> clearnet -> fast start;
|
||||
- hardened user (`true`) -> stays on Tor, automatically, with no new setting to discover.
|
||||
|
||||
That is the difference between "the app overrides you" and "the app treats its stand-in list the way
|
||||
you asked your own list to be treated".
|
||||
|
||||
## Privacy, for the PR body
|
||||
|
||||
The window correlates the user's **IP with their pubkey** at ~6 hardcoded relays, because the REQ
|
||||
asks those relays for that pubkey's events. A first login is the most sensitive moment there is.
|
||||
|
||||
What makes it defensible: **`trustedRelaysViaTor` already defaults to false**, so the moment
|
||||
kind-10002 lands the user's own relays are dialled over clearnet anyway. This moves an existing
|
||||
disclosure slightly earlier, to a different well-known set. It is not a new class of exposure for
|
||||
the default configuration — and it is *not* an AUTH disclosure, provided Finding 2 is respected.
|
||||
|
||||
If `trustedRelaysViaTor` ever becomes default-true, **this feature must be revisited in the same
|
||||
commit** — its justification disappears. Leave a comment at the default linking the two.
|
||||
|
||||
Residual, worth verifying rather than assuming: `useTor()` is keyed by relay **URL**, and the pool
|
||||
multiplexes every subscription for a URL over one socket. During the window, anything addressed to a
|
||||
default relay rides that clearnet socket — including a kind-1059 giftwrap subscription, since the DM
|
||||
list is also absent. Measure it (below) before deciding it is acceptable.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit — the rule itself, per list type: absent event -> defaults; present-but-empty -> **empty**;
|
||||
present-with-values -> values. The middle case is the regression guard and the one that fails today.
|
||||
|
||||
Unit (`TorRelayEvaluationTest`): an assumed relay resolves to `trustedRelaysViaTor` (both values);
|
||||
.onion / money-op / DM keep their own policy while also listed as assumed; a non-assumed "new" relay
|
||||
still resolves to `newRelaysViaTor`; an empty assumed set is byte-for-byte today's behaviour.
|
||||
|
||||
Unit: `isInMyRelayList` does **not** see assumed relays (guards Finding 2 permanently).
|
||||
|
||||
Device — the number that justifies the change. `relaytiming.sh` + `BootRelayDiag` census,
|
||||
`VERBOSE_LOGS=true` benchmark build, fresh install each, counterbalanced, n>=3:
|
||||
- primary: login -> first note; login -> own profile + follow list;
|
||||
- secondary: relays opened / serving / events at the 20s census;
|
||||
- guard: grep the verbose log for any request to a default relay during the window that is not for
|
||||
the account's own pubkey, and for any AUTH sent to one.
|
||||
|
||||
Harness traps (all in `[[fresh-install-routes-everything-via-tor]]`): the tablet raises its lock
|
||||
screen during long waits (`wm dismiss-keyguard`, not just `KEYCODE_WAKEUP`); the login layout shifts
|
||||
when the IME opens, so dismiss it before tapping fixed coordinates; `BACK` on the home screen exits
|
||||
the app; always assert the run left the login screen before trusting its timing.
|
||||
|
||||
## Expected outcome
|
||||
|
||||
Approach the Tor-OFF column: ≈**-7s to first paint, ~2x relay coverage** in the first 20s, with
|
||||
everything after the first event behaving exactly as today.
|
||||
|
||||
If the gain is materially smaller, the likely cause is that the feed is gated on outbox-discovered
|
||||
relays (which stay "new", hence Tor) rather than the user's own list — in which case the win is
|
||||
limited to profile and follows, and may not be worth the privacy cost. Decide on the numbers.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. Fix the absent-vs-empty bug in the four helpers + tests. Independently correct; ship separately.
|
||||
2. Add `relaysAssumedWhileUnknown` + `TorRelayState.assumedRelays` + the `useTor()` branch.
|
||||
3. Device A/B. Keep only if it earns its keep.
|
||||
+14
-3
@@ -25,7 +25,10 @@ import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorService
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -58,7 +61,7 @@ import kotlin.system.measureTimeMillis
|
||||
* 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest`
|
||||
*
|
||||
* **What it covers that [TorManagerTest] does not:**
|
||||
* - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind.
|
||||
* - Real `ArtiNative.initialize` → `create_unbootstrapped_async` → SOCKS listener bind.
|
||||
* - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump).
|
||||
* - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds.
|
||||
* - OkHttp routing traffic through the SOCKS port and Arti exiting through the
|
||||
@@ -73,7 +76,14 @@ import kotlin.system.measureTimeMillis
|
||||
@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.")
|
||||
class TorBootstrapInstrumentedTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val torService = TorService(context)
|
||||
|
||||
/**
|
||||
* [TorService] promotes Bootstrapping -> Active from a coroutine on this scope, so the test
|
||||
* must own one and cancel it — without a live scope `status` would never reach Active and every
|
||||
* assertion below would hang until its timeout.
|
||||
*/
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val torService = TorService(context, scope)
|
||||
|
||||
@After
|
||||
fun tearDown() =
|
||||
@@ -81,11 +91,12 @@ class TorBootstrapInstrumentedTest {
|
||||
// Drop the native client so this test's state file lock doesn't bleed into
|
||||
// the next instrumented run on the same device.
|
||||
torService.reset()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-start bootstrap. The whole point of the custom Arti build is that this
|
||||
* works at all — if create_bootstrapped panics (e.g., because we forgot to install
|
||||
* works at all — if client creation panics (e.g., because we forgot to install
|
||||
* a rustls CryptoProvider after an arti bump) the test catches it.
|
||||
*/
|
||||
@Test
|
||||
|
||||
+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.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Shared harness for the MediaSaverToDisk instrumented tests: writes a small payload
|
||||
* file, drives [MediaSaverToDisk.save] with the given MIME type, and asserts the save
|
||||
* reported success. Package-level support object per the AvifInstrumentedTestSupport
|
||||
* precedent.
|
||||
*/
|
||||
object MediaSaverTestSupport {
|
||||
/** Drives one save and fails the test if it reported an error or never succeeded. */
|
||||
fun saveAndAssertSuccess(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
) {
|
||||
val localFile = File(context.cacheDir, "media-saver-${UUID.randomUUID()}.bin")
|
||||
localFile.writeBytes(ByteArray(2048) { it.toByte() })
|
||||
|
||||
var failure: Throwable? = null
|
||||
var succeeded = false
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
MediaSaverToDisk.save(
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
context = context,
|
||||
onSuccess = { succeeded = true },
|
||||
onError = { failure = it },
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
localFile.delete()
|
||||
}
|
||||
|
||||
// Surfaces e.g. the #4009 IllegalArgumentException as the test failure message.
|
||||
assertNull("save() reported an error: ${failure?.message}", failure)
|
||||
assertTrue("save() never reported success", succeeded)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Covers the pre-Q writer, which MediaStore never sees: below API 29 saveContentDefault
|
||||
* writes straight to a public directory and lets the media scanner index it.
|
||||
*
|
||||
* That path used to hardcode Pictures for every content type, so videos, audio and PDFs
|
||||
* were all filed under Pictures/Amethyst. It now routes through the same MediaStoreTarget
|
||||
* as the MediaStore path. minSdk is 26, so this range ships.
|
||||
*
|
||||
* There is no JVM coverage of any of this: Build.VERSION.SDK_INT is 0 under
|
||||
* returnDefaultValues, so unit tests can only reach the routing function, never the writer.
|
||||
*
|
||||
* **Running this suite:** below Q the storage grant must exist before the app process
|
||||
* forks (external storage is mounted at fork time), and Gradle's connectedAndroidTest
|
||||
* installs and instruments with no window to grant in between - so these tests skip
|
||||
* under it. Drive them manually on an API 26-28 device:
|
||||
* ```
|
||||
* ./gradlew :amethyst:assemblePlayDebug :amethyst:assemblePlayDebugAndroidTest
|
||||
* adb install -r -g amethyst/build/outputs/apk/play/debug/amethyst-play-arm64-v8a-debug.apk
|
||||
* adb install -r -g amethyst/build/outputs/apk/androidTest/play/debug/amethyst-play-debug-androidTest.apk
|
||||
* adb shell am instrument -w -e class com.vitorpamplona.amethyst.ui.actions.MediaSaverToDiskLegacyStorageTest \
|
||||
* com.vitorpamplona.amethyst.debug.test/androidx.test.runner.AndroidJUnitRunner
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaSaverToDiskLegacyStorageTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** Every directory production can write to, straight from the routing table. */
|
||||
private val watchedDirs = MediaSaverToDisk.MediaStoreTarget.entries.map { it.relativeDirectory }
|
||||
private val createdFiles = mutableListOf<File>()
|
||||
|
||||
@Before
|
||||
fun onlyBelowScopedStorage() {
|
||||
assumeTrue("saveContentDefault only runs below API 29", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
|
||||
|
||||
// The legacy writer needs the runtime permission; no androidx.test:rules on the
|
||||
// classpath, so grant it through the instrumentation shell instead. The output has
|
||||
// to be drained: executeShellCommand runs asynchronously and closing the descriptor
|
||||
// early kills the command before it applies.
|
||||
val fd =
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.uiAutomation
|
||||
.executeShellCommand(
|
||||
"pm grant ${context.packageName} android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
)
|
||||
ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes() }
|
||||
|
||||
assertEquals(
|
||||
"WRITE_EXTERNAL_STORAGE was not granted; the legacy writer cannot be exercised",
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
)
|
||||
|
||||
// Holding the permission is not enough below Q: external storage is mounted into
|
||||
// the process when it forks, so a grant to an already-running process never
|
||||
// reaches it and every write fails with EACCES. Probe for real writability and
|
||||
// skip rather than report a routing failure that is really a harness problem.
|
||||
assumeTrue(
|
||||
"External storage is not writable by this process; below API 29 the grant must " +
|
||||
"exist at install time. See this class's KDoc for the exact run recipe.",
|
||||
canWriteToPublicStorage(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun canWriteToPublicStorage(): Boolean =
|
||||
try {
|
||||
val dir = amethystDir("Movies").apply { if (!exists()) mkdirs() }
|
||||
val probe = File(dir, ".write-probe-${System.nanoTime()}")
|
||||
val writable = probe.createNewFile()
|
||||
probe.delete()
|
||||
writable
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
createdFiles.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoGoesToMovies() = assertRoutes("video/mp4", "Movies")
|
||||
|
||||
@Test
|
||||
fun imageGoesToPictures() = assertRoutes("image/jpeg", "Pictures")
|
||||
|
||||
@Test
|
||||
fun audioGoesToMusic() = assertRoutes("audio/mpeg", "Music")
|
||||
|
||||
@Test
|
||||
fun pdfGoesToDownloads() = assertRoutes("application/pdf", "Download")
|
||||
|
||||
/**
|
||||
* Saves one file and asserts it appeared under [expectedDir]/Amethyst and nowhere else.
|
||||
* Checking the other directories is the point: the bug was everything landing in Pictures.
|
||||
*/
|
||||
private fun assertRoutes(
|
||||
mimeType: String,
|
||||
expectedDir: String,
|
||||
) {
|
||||
val before = snapshot()
|
||||
|
||||
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
|
||||
|
||||
val added = snapshot().mapValues { (dir, names) -> names - before.getValue(dir) }
|
||||
added.forEach { (dir, names) -> names.forEach { createdFiles.add(File(amethystDir(dir), it)) } }
|
||||
|
||||
val dirsThatGrew = added.filterValues { it.isNotEmpty() }.keys
|
||||
assertEquals("$mimeType should land only in $expectedDir/Amethyst", setOf(expectedDir), dirsThatGrew)
|
||||
assertEquals("expected exactly one new file", 1, added.getValue(expectedDir).size)
|
||||
}
|
||||
|
||||
private fun amethystDir(publicDir: String) = File(Environment.getExternalStoragePublicDirectory(publicDir), "Amethyst")
|
||||
|
||||
private fun snapshot(): Map<String, Set<String>> = watchedDirs.associateWith { amethystDir(it).list()?.toSet() ?: emptySet() }
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* End-to-end regression test for issue #4009: drives the real ContentResolver, so it
|
||||
* catches both symptoms of a collection/directory mismatch - Android 10 rejects the
|
||||
* insert outright (the quoted rejection lives in [MediaSaverToDisk.MediaStoreTarget]'s
|
||||
* KDoc), and later releases accept it and silently misfile the video.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaSaverToDiskMediaStoreTest {
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val resolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
/** Only rows this test inserted, as item Uris in the collection they went into. */
|
||||
private val created = mutableListOf<Uri>()
|
||||
|
||||
@Before
|
||||
fun requiresScopedStorage() {
|
||||
assumeTrue("saveContentQ only runs on API 29+", Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
created.forEach { resolver.delete(it, null, null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAVideoLandsInMoviesAndNotPictures() {
|
||||
val relativePath = saveAndReadBackRelativePath("video/mp4", MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
|
||||
|
||||
assertEquals("Movies/Amethyst/", relativePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savingAnImageStillLandsInPictures() {
|
||||
val relativePath = saveAndReadBackRelativePath("image/jpeg", MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
|
||||
assertEquals("Pictures/Amethyst/", relativePath)
|
||||
}
|
||||
|
||||
private fun saveAndReadBackRelativePath(
|
||||
mimeType: String,
|
||||
collection: Uri,
|
||||
): String? {
|
||||
// Anything at or below this id predates the test and must never be read or deleted:
|
||||
// this suite is meant to be runnable on a real device holding real media.
|
||||
val highWaterMark = maxIdIn(collection)
|
||||
|
||||
MediaSaverTestSupport.saveAndAssertSuccess(context, mimeType)
|
||||
|
||||
return rowInsertedAfter(collection, highWaterMark)
|
||||
}
|
||||
|
||||
private fun maxIdIn(collection: Uri): Long {
|
||||
resolver
|
||||
.query(collection, arrayOf(MediaStore.MediaColumns._ID), null, null, "${MediaStore.MediaColumns._ID} DESC")
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) return cursor.getLong(0)
|
||||
}
|
||||
return -1L
|
||||
}
|
||||
|
||||
/** Reads back the row the save just inserted and records it for cleanup. */
|
||||
private fun rowInsertedAfter(
|
||||
collection: Uri,
|
||||
highWaterMark: Long,
|
||||
): String? {
|
||||
resolver
|
||||
.query(
|
||||
collection,
|
||||
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.RELATIVE_PATH),
|
||||
"${MediaStore.MediaColumns._ID} > ?",
|
||||
arrayOf(highWaterMark.toString()),
|
||||
"${MediaStore.MediaColumns._ID} ASC",
|
||||
)?.use { cursor ->
|
||||
assertTrue("save() reported success but inserted no row into $collection", cursor.moveToFirst())
|
||||
created.add(ContentUris.withAppendedId(collection, cursor.getLong(0)))
|
||||
return cursor.getString(1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.MediaStoreTarget
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* [MediaStoreTarget] spells its directories out as literals because Environment's
|
||||
* DIRECTORY_* fields are plain statics that the unit-test android.jar leaves null.
|
||||
* This is the other half of that trade: on a real device the literals are checked
|
||||
* against the platform constants they stand in for.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaStoreTargetInstrumentedTest {
|
||||
@Test
|
||||
fun directoriesMatchThePlatformConstants() {
|
||||
assertEquals(Environment.DIRECTORY_PICTURES, MediaStoreTarget.IMAGES.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_MUSIC, MediaStoreTarget.AUDIO.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_MOVIES, MediaStoreTarget.VIDEO.relativeDirectory)
|
||||
assertEquals(Environment.DIRECTORY_DOWNLOADS, MediaStoreTarget.DOWNLOADS.relativeDirectory)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.actions.uploads
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pins what `res/xml/file_paths.xml` is allowed to hand out.
|
||||
*
|
||||
* The provider root used to be `<external-path path=".">`, i.e. the whole of
|
||||
* `Environment.getExternalStorageDirectory()`. It is now the app-specific
|
||||
* `<external-files-path>`, which is the only external location Amethyst ever
|
||||
* shares from (camera/video capture). These tests fail if either half of that
|
||||
* regresses: the capture paths must still resolve, and the external-storage
|
||||
* root must not.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FileProviderPathsTest {
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val authority = "${context.packageName}.provider"
|
||||
|
||||
@Test
|
||||
fun photoCaptureUriResolves() {
|
||||
val uri = getPhotoUri(context)
|
||||
assertEquals("content", uri.scheme)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoCaptureUriResolves() {
|
||||
val uri = getVideoUri(context)
|
||||
assertEquals("content", uri.scheme)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the external_files root, got $uri", uri.path!!.startsWith("/external_files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cacheDirStillResolves() {
|
||||
val file = File(context.cacheDir, "amethyst_share_probe.png")
|
||||
val uri = FileProvider.getUriForFile(context, authority, file)
|
||||
assertEquals(authority, uri.authority)
|
||||
assertTrue("expected the cache root, got $uri", uri.path!!.startsWith("/cache/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalStorageRootIsNoLongerShareable() {
|
||||
@Suppress("DEPRECATION")
|
||||
val outside = File(Environment.getExternalStorageDirectory(), "Download/not-ours.pdf")
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(context, authority, outside)
|
||||
fail("FileProvider should not map $outside, but produced $uri")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Correct: no configured root contains it.
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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.insets
|
||||
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Upstream regression test for androidx.compose.foundation:foundation-layout.
|
||||
*
|
||||
* A `WindowInsetsAnimation` that is prepared and started but never ended — which is what a
|
||||
* cancelled IME animation looks like — leaves `InsetsListener.runningAnimation` set forever.
|
||||
* `onApplyWindowInsets` then matches neither of its two branches, so `composeInsets.update()`
|
||||
* is never called again and `WindowInsets.ime` is dead for the life of the window.
|
||||
*
|
||||
* Introduced in 1.4.0 (absent in 1.3.0, where `onApplyWindowInsets` updated unconditionally
|
||||
* once `onStart` had cleared `prepared`). Still present in 1.12.0 and 1.13.0-alpha01. The
|
||||
* compensating self-heal (`view.post(this)` -> `run()`) is scoped to `SDK_INT == R`, so on
|
||||
* API 31+ nothing clears the flag; `WindowInsetsHolder.resetState()` only runs when the
|
||||
* holder's accessCount transitions 0 -> 1, which never happens in an app whose shell always
|
||||
* reads insets.
|
||||
*
|
||||
* Filed upstream as b/552500419.
|
||||
*
|
||||
* [aCancelledImeAnimationMustNotWedgeTheAnimatedInset] FAILS on every version from 1.4.0 on, so it
|
||||
* is [Ignore]d to keep CI green. It is not a test of Amethyst code — it is the upstream repro we
|
||||
* attached to the bug. **Re-run it by hand after every Compose upgrade**: when it passes, the
|
||||
* upstream fix has landed and [com.vitorpamplona.amethyst.ui.insets.SafeImeInsets] can be retired.
|
||||
*
|
||||
* [theAnimationTargetSurvivesTheWedge] documents the asymmetry that makes a workaround possible
|
||||
* and is expected to PASS — `updateImeAnimationTarget` is called outside the guard. It stays
|
||||
* enabled, because it guards the premise [com.vitorpamplona.amethyst.ui.insets.SafeImeInsets]
|
||||
* depends on: if a future Compose release stopped keeping `imeAnimationTarget` current, our
|
||||
* fallback would silently start reading a dead value too.
|
||||
*/
|
||||
class ComposeImeInsetWedgeTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private val keyboardHeight = 957
|
||||
|
||||
private fun imeInsets(bottom: Int): WindowInsetsCompat =
|
||||
WindowInsetsCompat
|
||||
.Builder()
|
||||
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom))
|
||||
.setVisible(WindowInsetsCompat.Type.ime(), bottom > 0)
|
||||
.build()
|
||||
|
||||
/** Compose's own listener for this view. Private class, but both interfaces it exposes are public. */
|
||||
private fun listenerFor(view: View): Any {
|
||||
val holderClass = Class.forName("androidx.compose.foundation.layout.WindowInsetsHolder")
|
||||
val companion =
|
||||
holderClass.getDeclaredField("Companion").run {
|
||||
isAccessible = true
|
||||
get(null)
|
||||
}
|
||||
val holder =
|
||||
companion.javaClass
|
||||
.getDeclaredMethod("getOrCreateFor", View::class.java)
|
||||
.run {
|
||||
isAccessible = true
|
||||
invoke(companion, view)
|
||||
}
|
||||
return holderClass.getDeclaredField("insetsListener").run {
|
||||
isAccessible = true
|
||||
get(holder)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun anim() = WindowInsetsAnimationCompat(WindowInsetsCompat.Type.ime(), LinearInterpolator(), 250L)
|
||||
|
||||
private fun bounds() =
|
||||
WindowInsetsAnimationCompat.BoundsCompat(
|
||||
Insets.NONE,
|
||||
Insets.of(0, 0, 0, keyboardHeight),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
@Ignore("Fails by design until upstream fixes b/552500419 — re-run by hand on every Compose upgrade")
|
||||
fun aCancelledImeAnimationMustNotWedgeTheAnimatedInset() {
|
||||
var animated by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
animated = WindowInsets.ime.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
// Baseline: with no animation in flight the inset tracks normally.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals("baseline: the inset must follow a plain dispatch", keyboardHeight, animated)
|
||||
|
||||
// A cancelled animation: prepared and started, but onEnd never arrives.
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
// The keyboard is gone and the window says so. The animated inset must follow.
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"WindowInsets.ime must still track the window after an animation was cancelled " +
|
||||
"without onEnd; it is instead frozen at the keyboard height forever",
|
||||
0,
|
||||
animated,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Test
|
||||
fun theAnimationTargetSurvivesTheWedge() {
|
||||
var target by mutableIntStateOf(-1)
|
||||
lateinit var view: View
|
||||
|
||||
rule.setContent {
|
||||
view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
target = WindowInsets.imeAnimationTarget.getBottom(density)
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
val listener = listenerFor(view)
|
||||
val onApply = listener as OnApplyWindowInsetsListener
|
||||
val callback = listener as WindowInsetsAnimationCompat.Callback
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(keyboardHeight)) }
|
||||
rule.waitForIdle()
|
||||
assertEquals(keyboardHeight, target)
|
||||
|
||||
rule.runOnUiThread {
|
||||
callback.onPrepare(anim())
|
||||
callback.onStart(anim(), bounds())
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
rule.runOnUiThread { onApply.onApplyWindowInsets(view, imeInsets(0)) }
|
||||
rule.waitForIdle()
|
||||
|
||||
assertEquals(
|
||||
"updateImeAnimationTarget is called outside the guard, so this reading stays truthful",
|
||||
0,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The feed's animated elements defer building their `Transition` until a value actually changes,
|
||||
* because first composition has nothing to animate and building one per card per scroll is pure
|
||||
* waste (measured: roughly half the composition cost of every reaction-row button).
|
||||
*
|
||||
* The whole point of deferring rather than removing is that the animation must still play. These
|
||||
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
|
||||
* happens right after the transition is lazily created — still shows outgoing and incoming content
|
||||
* simultaneously, which only a running animation does. A regression that turned the deferral into a
|
||||
* plain snap would show exactly one of them and fail here.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DeferredAnimationTest {
|
||||
@get:Rule
|
||||
val rule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun deferredCrossfadeStillAnimatesTheFirstChange() {
|
||||
val state = mutableStateOf("A")
|
||||
rule.mainClock.autoAdvance = false
|
||||
|
||||
rule.setContent {
|
||||
DeferredCrossfade(
|
||||
targetState = state.value,
|
||||
modifier = Modifier,
|
||||
contentAlignment = Alignment.TopStart,
|
||||
animationSpec = tween(DURATION_MS),
|
||||
label = "test",
|
||||
) { value ->
|
||||
Text(value, modifier = Modifier.testTag("text_$value"))
|
||||
}
|
||||
}
|
||||
|
||||
// Before any change the transition has not been built, and only the current value renders.
|
||||
rule.onNodeWithTag("text_A").assertIsDisplayed()
|
||||
rule.onNodeWithTag("text_B").assertDoesNotExist()
|
||||
|
||||
state.value = "B"
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
|
||||
|
||||
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
|
||||
rule.onNodeWithTag("text_A").assertExists()
|
||||
rule.onNodeWithTag("text_B").assertExists()
|
||||
|
||||
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
|
||||
rule.onNodeWithTag("text_B").assertIsDisplayed()
|
||||
rule.onNodeWithTag("text_A").assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DURATION_MS = 300
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.ai
|
||||
class NoOpWritingAssistant : WritingAssistant {
|
||||
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Unavailable
|
||||
|
||||
override suspend fun requestDownload(): WritingAssistantStatus = WritingAssistantStatus.Unavailable
|
||||
|
||||
override suspend fun transform(
|
||||
text: String,
|
||||
tone: WritingTone,
|
||||
|
||||
+3
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.service.ai
|
||||
import android.content.Context
|
||||
|
||||
object WritingAssistantFactory {
|
||||
/** Whether this flavor ships a real assistant. Drives the Settings tile. */
|
||||
const val IS_SUPPORTED = false
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun create(context: Context): WritingAssistant = NoOpWritingAssistant()
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
@@ -453,7 +454,7 @@
|
||||
|
||||
<service
|
||||
android:name=".service.call.CallForegroundService"
|
||||
android:foregroundServiceType="microphone|camera|phoneCall"
|
||||
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
|
||||
android:stopWithTask="false"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -579,6 +580,17 @@
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- Invisible host that runs the system file picker for an embedded WebView surface. The
|
||||
`:napplet` providers are windowless services with no Activity of their own, so the main
|
||||
process collects the pick and relays the URIs back to the sandbox. -->
|
||||
<!-- Standard launch mode on purpose: two embedded surfaces can each have a pick in flight, and
|
||||
singleTop would collapse the second onto the first and strand its page's file input. -->
|
||||
<activity
|
||||
android:name=".napplet.WebFileChooserActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<!-- First-connect "Connect to Nostr" dialog. -->
|
||||
<activity
|
||||
android:name=".connectedApps.consent.SignerConnectActivity"
|
||||
|
||||
@@ -57,6 +57,9 @@ import java.io.File
|
||||
*/
|
||||
class Amethyst : Application() {
|
||||
init {
|
||||
// Deliberately in init, not onCreate: this runs in EVERY process, including the
|
||||
// :napplet sandbox, whose onCreate early-returns. Moving it would leave that
|
||||
// process on the wrapper's DEBUG default.
|
||||
Log.minLevel = DEFAULT_LOG_LEVEL
|
||||
Log.d("AmethystApp") { "Creating App $this" }
|
||||
}
|
||||
@@ -82,9 +85,14 @@ class Amethyst : Application() {
|
||||
*/
|
||||
val DEFAULT_LOG_LEVEL: LogLevel =
|
||||
when {
|
||||
!BuildConfig.DEBUG -> LogLevel.WARN
|
||||
VERBOSE_LOGS -> LogLevel.DEBUG
|
||||
else -> LogLevel.INFO
|
||||
// `isDebug` also covers the `benchmark` build type — a release build (R8 + AOT)
|
||||
// that exists purely to be measured and is never shipped. Treating it as a release
|
||||
// build left it at WARN, which drops every INFO milestone the boot narrative is
|
||||
// made of (account load timings, Tor status transitions, the relay census), so the
|
||||
// one variant whose numbers are trustworthy was also the one we could not read.
|
||||
VERBOSE_LOGS && isDebug -> LogLevel.DEBUG
|
||||
isDebug -> LogLevel.INFO
|
||||
else -> LogLevel.WARN
|
||||
}
|
||||
|
||||
lateinit var instance: AppModules
|
||||
|
||||
@@ -50,11 +50,13 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
@@ -130,7 +132,6 @@ 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
|
||||
@@ -208,6 +209,19 @@ class AppModules(
|
||||
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
||||
|
||||
/**
|
||||
* Mints and caches BUD-01 read-auth tokens for auth-gated Blossom hosts.
|
||||
* Shared by the OkHttp interceptor (which only reads the cache) and Coil's
|
||||
* [com.vitorpamplona.amethyst.service.images.BlossomReadAuthFetcher] (which
|
||||
* awaits a signature), so both see one token and one in-flight signature per
|
||||
* host. Signing runs on [applicationIOScope], never on an OkHttp thread.
|
||||
*/
|
||||
val blossomReadAuthTokens =
|
||||
BlossomReadAuthTokenProvider(
|
||||
signerProvider = { sessionManager.loggedInAccount()?.signer },
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
|
||||
|
||||
@@ -269,7 +283,7 @@ class AppModules(
|
||||
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
|
||||
}
|
||||
|
||||
private val torService = TorService(appContext)
|
||||
private val torService = TorService(appContext, applicationIOScope)
|
||||
val torManager = TorManager(torPrefs, torService, applicationIOScope)
|
||||
|
||||
// Network identity change (wifi↔cellular, regained from offline, captive portal
|
||||
@@ -292,6 +306,11 @@ class AppModules(
|
||||
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
|
||||
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
|
||||
|
||||
// Restore + persist which drawer section headings the user has folded away, so the side menu
|
||||
// opens the way they left it (device-global: a collapsed heading is a per-device view choice,
|
||||
// not an account setting worth syncing, unlike the hidden rows beside it in the drawer).
|
||||
val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope)
|
||||
|
||||
// Service that will run at all times to receive events from Pokey
|
||||
val pokeyReceiver = PokeyReceiver()
|
||||
|
||||
@@ -401,7 +420,11 @@ class AppModules(
|
||||
init {
|
||||
applicationIOScope.launch {
|
||||
torService.status
|
||||
.map { it is TorServiceStatus.Active }
|
||||
// Battery ledger: Tor is doing work from the moment the client exists — the
|
||||
// directory download is the most expensive part of a launch — so this tracks
|
||||
// "running", not "bootstrapped". Keying it on Active alone would silently omit the
|
||||
// 12-34s download from every cold start.
|
||||
.map { it.socksPort != null }
|
||||
.distinctUntilChanged()
|
||||
.collect { torSession.setActive(it) }
|
||||
}
|
||||
@@ -448,9 +471,8 @@ class AppModules(
|
||||
// tracks the logged-in account.
|
||||
blossomReadAuth =
|
||||
BlossomReadAuthInterceptor(
|
||||
BlossomReadAuthTokenProvider(
|
||||
signerProvider = { sessionManager.loggedInAccount()?.signer },
|
||||
)::authHeader,
|
||||
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
|
||||
onAuthRequired = blossomReadAuthTokens::warm,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -635,7 +657,7 @@ class AppModules(
|
||||
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
|
||||
// ignoreRetryDelays=true) the instant Tor flips to Active.
|
||||
canDial = { url ->
|
||||
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
|
||||
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isTorReady()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -704,7 +726,7 @@ class AppModules(
|
||||
TorCircuitHealthTracker(
|
||||
client = client,
|
||||
isTorRouted = { torEvaluatorFlow.shouldUseTorForRelay(it) },
|
||||
isTorActive = { torManager.isSocksReady() },
|
||||
isTorActive = { torManager.isTorReady() },
|
||||
isConnectivityActive = { connManager.status.value is ConnectivityStatus.Active },
|
||||
onCircuitsDead = { torManager.onTorCircuitsDead() },
|
||||
).also { it.register() }
|
||||
@@ -1083,6 +1105,7 @@ class AppModules(
|
||||
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
|
||||
thumbnailCache = thumbnailDiskCache,
|
||||
backgroundScope = applicationIOScope,
|
||||
readAuth = blossomReadAuthTokens,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -21,12 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@@ -24,7 +24,6 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -41,6 +40,7 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
|
||||
@@ -145,7 +145,7 @@ object FavoriteAppLauncher {
|
||||
profile = HostProfile.WEBSITE,
|
||||
)
|
||||
else -> {
|
||||
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
|
||||
Log.w("FavoriteAppLauncher") { "Favorited app not resolvable yet: $coordinate" }
|
||||
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSig
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
|
||||
@@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
@@ -138,6 +138,7 @@ import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
|
||||
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
|
||||
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.AssumedRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState
|
||||
@@ -384,12 +385,16 @@ class Account(
|
||||
// doubles as the attribution pubkey for ExplainedFilter.accountPubKeys.
|
||||
override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex
|
||||
|
||||
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList }
|
||||
// No ifEmpty here on purpose: an empty kind:10086 is the user asking for no indexers, and
|
||||
// IndexerRelayListState already substitutes the defaults for the only case we may override —
|
||||
// never having seen the event. Re-substituting here would undo that choice.
|
||||
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value
|
||||
|
||||
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value
|
||||
|
||||
// searchRelayList.flow already applies the DefaultSearchRelayList fallback internally
|
||||
// (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here.
|
||||
// searchRelayList.flow applies DefaultSearchRelayList internally when no kind:10007 has ever
|
||||
// been seen (SearchRelayListState.normalizeSearchRelayListWithBackup); an empty published list
|
||||
// stays empty. No ifEmpty here either way.
|
||||
override fun searchRelays(): Set<NormalizedRelayUrl> = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet()
|
||||
|
||||
override fun searchOnlyRelays(): Set<NormalizedRelayUrl> = searchRelayList.flow.value
|
||||
@@ -809,6 +814,9 @@ class Account(
|
||||
|
||||
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
|
||||
|
||||
/** Relays guessed on the user's behalf until their own lists arrive. Read only by Tor routing. */
|
||||
val assumedRelays = AssumedRelayListsState(nip65RelayList, searchRelayList, indexerRelayList, scope)
|
||||
|
||||
// Follows Relays
|
||||
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
|
||||
|
||||
@@ -840,6 +848,30 @@ class Account(
|
||||
|
||||
val newNotesPreProcessor = EventProcessor(this, cache)
|
||||
|
||||
/**
|
||||
* Owns the WebRTC call state machine.
|
||||
*
|
||||
* Account-scoped on purpose: a call outlives the main UI. It runs in its own
|
||||
* [com.vitorpamplona.amethyst.ui.call.CallActivity] (a separate task, since MainActivity is
|
||||
* `singleInstance`) backed by a foreground service, so Android is free to destroy the
|
||||
* backgrounded MainActivity while the call is up — which it does routinely, e.g. a few hundred
|
||||
* milliseconds after CallActivity enters picture-in-picture on HOME. While this lived on
|
||||
* `AccountViewModel` (and ran on `viewModelScope`), that destruction cleared the ViewModel and
|
||||
* reset the call to Idle, hanging up mid-conversation.
|
||||
*
|
||||
* Torn down with the account: [scope] is cancelled by
|
||||
* `AccountCacheState.removeAccount`, which also calls [CallManager.dispose] for the
|
||||
* independent watchdog scope.
|
||||
*/
|
||||
val callManager =
|
||||
CallManager(
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
isFollowing = { isFollowing(it) },
|
||||
publishEvent = { wrap -> scope.launch { publishCallSignaling(wrap) } },
|
||||
isCallsEnabled = { settings.callsEnabled.value },
|
||||
)
|
||||
|
||||
// Per-message publish acceptance (relay OKs), feeding the delivery ticks on
|
||||
// own chat bubbles.
|
||||
val chatDeliveryTracker = ChatDeliveryTracker(client)
|
||||
@@ -3624,6 +3656,10 @@ class Account(
|
||||
init {
|
||||
Log.d("AccountRegisterObservers", "Init")
|
||||
|
||||
// Route incoming call signaling into the state machine as soon as the account exists, so
|
||||
// offers are not missed while no UI is mounted.
|
||||
newNotesPreProcessor.callManager = callManager
|
||||
|
||||
// Blocking a relay has to forget any "just for now" login to it, or unblocking later would
|
||||
// silently resume authenticating off an answer given before the block. Blocking is the
|
||||
// strongest signal available here — the weaker "never allow" already drops the grant via
|
||||
|
||||
@@ -1381,7 +1381,7 @@ class AccountConcordActions(
|
||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
Log.i("Concord") { "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}" }
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
|
||||
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
||||
}
|
||||
@@ -1521,11 +1521,10 @@ class AccountConcordActions(
|
||||
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L)
|
||||
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
|
||||
val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0
|
||||
Log.d(
|
||||
"Concord",
|
||||
Log.d("Concord") {
|
||||
"importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " +
|
||||
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}",
|
||||
)
|
||||
"newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}"
|
||||
}
|
||||
newest?.let { account.cache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
@@ -1600,6 +1599,6 @@ class AccountConcordActions(
|
||||
val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) }
|
||||
var drained = 0
|
||||
account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ }
|
||||
Log.d("Concord", "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)")
|
||||
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +788,62 @@ class AccountSettings(
|
||||
// list names
|
||||
// ---
|
||||
|
||||
/**
|
||||
* All per-screen persisted feed filters paired with their factory default.
|
||||
* Deleting a list (NIP-51 people list / follow pack) must reset any screen whose
|
||||
* filter still points at the deleted address — otherwise the screen keeps a
|
||||
* dangling [TopFilter.PeopleList] that re-creates an empty AddressableNote shell
|
||||
* on every start and shows the list's dTag/UUID in the top bar instead of a name.
|
||||
*/
|
||||
private val feedFiltersWithDefaults: List<Pair<MutableStateFlow<TopFilter>, TopFilter>> =
|
||||
listOf(
|
||||
defaultHomeFollowList to TopFilter.AllFollows,
|
||||
defaultStoriesFollowList to TopFilter.Global,
|
||||
defaultNotificationFollowList to TopFilter.Selected,
|
||||
defaultDiscoveryFollowList to TopFilter.Global,
|
||||
defaultPollsFollowList to TopFilter.Global,
|
||||
defaultPicturesFollowList to TopFilter.Global,
|
||||
defaultNappletsFollowList to TopFilter.Global,
|
||||
defaultNsitesFollowList to TopFilter.Global,
|
||||
defaultWorkoutsFollowList to TopFilter.Global,
|
||||
defaultGitRepositoriesFollowList to TopFilter.Global,
|
||||
defaultHighlightsFollowList to TopFilter.Global,
|
||||
defaultCalendarsFollowList to TopFilter.Global,
|
||||
defaultProductsFollowList to TopFilter.AroundMe,
|
||||
defaultShortsFollowList to TopFilter.Global,
|
||||
defaultPublicChatsFollowList to TopFilter.Global,
|
||||
defaultLiveStreamsFollowList to TopFilter.Global,
|
||||
defaultNestsFollowList to TopFilter.Global,
|
||||
defaultLongsFollowList to TopFilter.Global,
|
||||
defaultArticlesFollowList to TopFilter.AllFollows,
|
||||
defaultMusicTracksFollowList to TopFilter.Global,
|
||||
defaultMusicPlaylistsFollowList to TopFilter.Global,
|
||||
defaultPodcastEpisodesFollowList to TopFilter.Global,
|
||||
defaultPodcastsFollowList to TopFilter.Global,
|
||||
defaultSoftwareAppsFollowList to TopFilter.Global,
|
||||
defaultBadgesFollowList to TopFilter.Mine,
|
||||
defaultBrowseEmojiSetsFollowList to TopFilter.Global,
|
||||
defaultCommunitiesFollowList to TopFilter.AllFollows,
|
||||
defaultFollowPacksFollowList to TopFilter.Global,
|
||||
defaultAppRecommendationsFollowList to TopFilter.Global,
|
||||
defaultRelayGroupsDiscoveryFollowList to TopFilter.Mine,
|
||||
)
|
||||
|
||||
/** Resets every persisted feed filter that points at the deleted list's address. */
|
||||
fun resetFeedFiltersPointingTo(address: Address) {
|
||||
var changed = false
|
||||
|
||||
feedFiltersWithDefaults.forEach { (flow, default) ->
|
||||
val current = flow.value
|
||||
if (current is TopFilter.AddressableTopFilter && current.address == address) {
|
||||
flow.tryEmit(default)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) saveAccountSettings()
|
||||
}
|
||||
|
||||
fun changeDefaultHomeFollowList(name: FeedDefinition) {
|
||||
changeDefaultHomeFollowList(name.code)
|
||||
}
|
||||
|
||||
@@ -99,18 +99,20 @@ class AccountZapActions(
|
||||
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onTimeout, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm,
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): HexKey {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onTimeout, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
return event.id
|
||||
}
|
||||
@@ -127,12 +129,20 @@ class AccountZapActions(
|
||||
*/
|
||||
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
|
||||
|
||||
/**
|
||||
* @param onTimeout invoked when no kind-23195 reply arrives before
|
||||
* [NwcSignerState.NWC_RESPONSE_TIMEOUT_MS]. Pass one on any path with a user
|
||||
* watching: without it a response lost in transit is indistinguishable from
|
||||
* the action never having happened.
|
||||
*/
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onTimeout, metadata, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
|
||||
@@ -825,6 +825,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a kind-5 event from the addressable's own author has deleted this
|
||||
* address. Works for empty addressable shells whose event is not loaded yet.
|
||||
*/
|
||||
fun hasBeenDeleted(address: Address): Boolean = deletionIndex.hasBeenDeleted(address, address.pubKeyHex)
|
||||
|
||||
fun getOrAddAliasNote(
|
||||
idHex: String,
|
||||
note: Note,
|
||||
@@ -1272,6 +1278,22 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
is GitPullRequestUpdateEvent -> {
|
||||
// Link the update to its parent PR so it lands in the PR's
|
||||
// replies collection (and picks up its target for threading).
|
||||
// The repository ATag isn't a reply target — skip it.
|
||||
listOfNotNull(event.parentPullRequestId()?.let { checkGetOrCreateNote(it) })
|
||||
}
|
||||
|
||||
is GitStatusEvent -> {
|
||||
// A status event roots itself at a patch/PR/issue via a
|
||||
// marked-`root` `e` tag; link only that so the transition
|
||||
// appears in the target's replies (GitStatusIndex reduces the
|
||||
// observed stream separately and doesn't need this wiring, but
|
||||
// ThreadFeedView and the notifications-tab reply chain do).
|
||||
listOfNotNull(event.rootEventId()?.let { checkGetOrCreateNote(it) })
|
||||
}
|
||||
|
||||
is TextNoteEvent -> {
|
||||
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
@@ -3102,6 +3124,16 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val requestId = event.requestId()
|
||||
|
||||
// Duplicate delivery, checked before the tracker so the warnings below mean one
|
||||
// thing each. Some NWC relays replay every cached kind-23195 whenever the REQ
|
||||
// filter changes (see NWCPaymentFilterAssembler), so an already-answered response
|
||||
// arrives again and again. Its first copy consumed the pending request, so the
|
||||
// replays would otherwise be reported as "no pending request is registered" —
|
||||
// the same line a genuinely late response produces, which made the two
|
||||
// indistinguishable in the field.
|
||||
if (getNoteIfExists(event.id)?.event != null) return false
|
||||
|
||||
val pending =
|
||||
when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) {
|
||||
is NwcPaymentTracker.MatchResult.Matched -> {
|
||||
@@ -3121,9 +3153,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
}
|
||||
|
||||
NwcPaymentTracker.MatchResult.NoMatch -> {
|
||||
// Not a replay — those are filtered above — so this is the first time we
|
||||
// have seen this response and nothing is waiting for it.
|
||||
Log.w("LocalCache") {
|
||||
"NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
|
||||
"The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag."
|
||||
"The response arrived after the client gave up waiting, the user holds a stale subscription, " +
|
||||
"or the wallet service set the wrong e tag."
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3137,7 +3172,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
// Backstop for a concurrent delivery that loaded the event between the replay
|
||||
// check above and here. Same outcome, no warning: it is not a protocol problem.
|
||||
if (note.event != null) return false
|
||||
|
||||
if (wasVerified || justVerify(event)) {
|
||||
|
||||
+7
-4
@@ -90,6 +90,9 @@ class AccountCacheState(
|
||||
accounts.update { existingAccounts ->
|
||||
val oldValue = existingAccounts[pubkey]
|
||||
oldValue?.scope?.cancel()
|
||||
// CallManager keeps its own watchdog scope, independent of the account scope
|
||||
// cancelled above, so it has to be disposed explicitly.
|
||||
oldValue?.callManager?.dispose()
|
||||
// Unregisters the tracker's persistent listener from the shared
|
||||
// client; without this every removed account leaks a listener.
|
||||
oldValue?.chatDeliveryTracker?.destroy()
|
||||
@@ -111,7 +114,7 @@ class AccountCacheState(
|
||||
loadAccount(accountSettings)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}: ${e.message}", e)
|
||||
Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,7 @@ class AccountCacheState(
|
||||
fun deleteAccountFiles(pubkey: HexKey) {
|
||||
val dir = File(accountsRootDir(), pubkey)
|
||||
if (dir.exists() && !dir.deleteRecursively()) {
|
||||
Log.w("AccountCacheState", "Failed to delete account directory ${dir.absolutePath}")
|
||||
Log.w("AccountCacheState") { "Failed to delete account directory ${dir.absolutePath}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +163,7 @@ class AccountCacheState(
|
||||
if (child.deleteRecursively()) {
|
||||
Log.d("AccountCacheState") { "Pruned orphan account dir ${child.name.take(8)}…" }
|
||||
} else {
|
||||
Log.w("AccountCacheState", "Failed to prune orphan account dir ${child.absolutePath}")
|
||||
Log.w("AccountCacheState") { "Failed to prune orphan account dir ${child.absolutePath}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,7 +285,7 @@ class AccountCacheState(
|
||||
Dispatchers.IO +
|
||||
SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable)
|
||||
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception", throwable)
|
||||
},
|
||||
),
|
||||
mlsGroupStateStore = mlsStore,
|
||||
|
||||
+84
-15
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,16 +38,23 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* supported RPC methods, and whether it emits notifications.
|
||||
*
|
||||
* Entries expire after [ttlSeconds] (default 2 days) so a wallet that later
|
||||
* changes its advertised capabilities is eventually re-checked. Reads never block
|
||||
* on the network:
|
||||
* changes its advertised capabilities is eventually re-checked. Four entry
|
||||
* points, in increasing order of how much they will wait:
|
||||
*
|
||||
* - [current] returns whatever is cached (possibly stale, possibly null) with no
|
||||
* side effect — for the payment hot path.
|
||||
* side effect and never blocks — for callers that can act on "don't know".
|
||||
* - [refreshIfStale] triggers a background fetch when the entry is missing or
|
||||
* expired, and returns immediately — call it right before using a wallet so a
|
||||
* stale entry self-heals without holding up the transaction.
|
||||
* - [getFresh] is the suspending variant for callers that can await (e.g. the
|
||||
* notification watcher deciding whether to open a subscription).
|
||||
* - [currentOrFetch] waits only when nothing at all is cached, and returns a
|
||||
* stale entry as-is — for callers where "don't know" and "no" are different
|
||||
* answers, such as NIP-44 negotiation.
|
||||
* - [getFresh] waits whenever the entry is missing *or* expired, for a caller
|
||||
* that must not act on a stale answer. No production caller needs that today.
|
||||
*
|
||||
* Every fetching path funnels through one request per wallet, so the startup
|
||||
* warm-up, a payment waiting on a cold cache and the notification watcher join
|
||||
* the same call rather than racing each other.
|
||||
*
|
||||
* A completed fetch — including a definitive "wallet published no info event"
|
||||
* (null) — is cached with a timestamp. A *failed* fetch (network error/timeout)
|
||||
@@ -65,7 +73,9 @@ class NwcInfoCache(
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<HexKey, Entry>()
|
||||
private val inFlight = ConcurrentHashMap.newKeySet<HexKey>()
|
||||
|
||||
// Fetches in progress, keyed like [cache]. Every fetching path goes through [fetchOnce].
|
||||
private val inFlight = ConcurrentHashMap<HexKey, CompletableDeferred<NwcInfoEvent?>>()
|
||||
|
||||
private fun isFresh(entry: Entry): Boolean = now() - entry.fetchedAt < ttlSeconds
|
||||
|
||||
@@ -80,26 +90,85 @@ class NwcInfoCache(
|
||||
fun refreshIfStale(uri: Nip47WalletConnect.Nip47URINorm) {
|
||||
val entry = cache[uri.pubKeyHex]
|
||||
if (entry != null && isFresh(entry)) return
|
||||
if (!inFlight.add(uri.pubKeyHex)) return
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
fetchAndStore(uri)
|
||||
} finally {
|
||||
inFlight.remove(uri.pubKeyHex)
|
||||
}
|
||||
}
|
||||
scope.launch(Dispatchers.IO) { fetchOnce(uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until a fresh-enough info event is available, fetching when the
|
||||
* entry is missing or expired. Returns the last cached (possibly stale) value
|
||||
* if the fetch fails.
|
||||
*
|
||||
* For a caller that must not act on a stale answer. No production caller needs
|
||||
* that today; prefer [currentOrFetch], which only waits on a cold cache.
|
||||
*/
|
||||
suspend fun getFresh(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
val entry = cache[uri.pubKeyHex]
|
||||
if (entry != null && isFresh(entry)) return entry.info
|
||||
return fetchAndStore(uri)
|
||||
return fetchOnce(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whatever is cached, waiting for a fetch only when there is nothing
|
||||
* cached at all.
|
||||
*
|
||||
* This is the encryption-negotiation entry point. [current] answers "what does
|
||||
* this wallet advertise" from memory, but on a cold cache it answers null, and
|
||||
* a null there is indistinguishable from "no NIP-44" — so the caller silently
|
||||
* downgrades to NIP-04 on the first transaction after every app start. Waiting
|
||||
* once, only when nothing is known, removes that.
|
||||
*
|
||||
* A stale entry is returned as-is without waiting: it still says which
|
||||
* encryption the wallet advertises, and [refreshIfStale] self-heals it in the
|
||||
* background for next time.
|
||||
*/
|
||||
suspend fun currentOrFetch(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
val entry = cache[uri.pubKeyHex] ?: return fetchOnce(uri)
|
||||
refreshIfStale(uri)
|
||||
return entry.info
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [fetchAndStore] for [uri] exactly once, however many callers ask at
|
||||
* once; every caller awaits that one result.
|
||||
*
|
||||
* The fetch runs in this cache's own [scope], never in the caller's. A caller
|
||||
* on the payment path is a `viewModelScope` coroutine that dies when the user
|
||||
* backs out of the screen — owning the fetch there would abandon it, leave the
|
||||
* cache cold and make the next attempt pay the whole cost again. Here a caller
|
||||
* giving up cancels only its own `await`, and the fetch it started still lands.
|
||||
*/
|
||||
private suspend fun fetchOnce(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
val key = uri.pubKeyHex
|
||||
val ours = CompletableDeferred<NwcInfoEvent?>()
|
||||
// putIfAbsent returns the previous entry, so a non-null result means
|
||||
// someone else already started this wallet's fetch.
|
||||
inFlight.putIfAbsent(key, ours)?.let { return it.await() }
|
||||
|
||||
val job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
var info: NwcInfoEvent? = null
|
||||
try {
|
||||
info = fetchAndStore(uri)
|
||||
} finally {
|
||||
// Non-suspending, so awaiters are released even if the fetch is cancelled.
|
||||
inFlight.remove(key, ours)
|
||||
ours.complete(info)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope that was already cancelled — this account was logged off while a
|
||||
// payment was in flight — never runs the body, so the finally above never
|
||||
// releases anyone. Without this, the caller awaits forever and the abandoned
|
||||
// slot makes every later call for this wallet do the same. Fires immediately
|
||||
// when the job is already complete, and is a no-op on the normal path.
|
||||
job.invokeOnCompletion {
|
||||
if (!ours.isCompleted) {
|
||||
inFlight.remove(key, ours)
|
||||
ours.complete(null)
|
||||
}
|
||||
}
|
||||
return ours.await()
|
||||
}
|
||||
|
||||
private suspend fun fetchAndStore(uri: Nip47WalletConnect.Nip47URINorm): NwcInfoEvent? {
|
||||
|
||||
+140
-29
@@ -37,11 +37,15 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.tags.ExtensionsTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -60,6 +64,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account.
|
||||
@@ -135,17 +140,58 @@ class NwcSignerState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking read of the negotiated encryption preference for a wallet.
|
||||
* NIP-47 says a client "should always prefer nip44 if supported by the wallet
|
||||
* service". Returns true only when the cached info event advertises `nip44_v2`;
|
||||
* otherwise NIP-04 (the legacy default). Also nudges a background refresh so a
|
||||
* stale/expired entry self-heals for the next transaction without blocking this
|
||||
* one.
|
||||
* The negotiated encryption preference for a wallet. NIP-47 says a client
|
||||
* "should always prefer nip44 if supported by the wallet service", so a false
|
||||
* here has to mean "the wallet does not offer NIP-44" — not "we have not asked
|
||||
* yet". [walletInfo] is what makes that distinction true.
|
||||
*/
|
||||
private fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
|
||||
uri ?: return false
|
||||
infoCache?.refreshIfStale(uri)
|
||||
return infoCache?.current(uri)?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } ?: false
|
||||
private fun prefersNip44(info: NwcInfoEvent?): Boolean = info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
|
||||
|
||||
/**
|
||||
* The wallet's advertised capabilities: the one place a send waits on them, and
|
||||
* it waits AT MOST ONCE.
|
||||
*
|
||||
* WAITING IS THE POINT. The info cache is per-account and in memory only, so it
|
||||
* starts empty on every app launch, and reading it without waiting makes "not
|
||||
* fetched yet" indistinguishable from "not supported". That shipped twice: the
|
||||
* first transaction to each wallet after a launch fell back to NIP-04 against a
|
||||
* wallet advertising `nip44_v2`, and a payment to a wallet that had been
|
||||
* advertising NWC-06 for twenty minutes still went out bare — with nothing, on
|
||||
* either side, reporting an error.
|
||||
*
|
||||
* ONCE, because both questions read the same event. Each used to fetch for
|
||||
* itself, which is free on a warm cache and doubles the stall on a cold one:
|
||||
* [NwcInfoCache] deliberately does not cache a FAILED fetch, so with the relay
|
||||
* down both waits ran in full and a 3s worst case became 6s.
|
||||
*
|
||||
* BOUNDED, because this sits in front of a payment the user has already tapped
|
||||
* and the no-response timer does not start until it returns. On expiry the
|
||||
* answer is null — read as NIP-04 and as no-metadata, both of them the safe
|
||||
* direction — while the fetch keeps running in the cache's own scope so the next
|
||||
* request gets the negotiated scheme. Never bound the fetch itself instead: a
|
||||
* null from it is cached as a definitive "no info event" for the whole TTL,
|
||||
* which would pin the wallet to NIP-04 for days.
|
||||
*/
|
||||
private suspend fun walletInfo(uri: Nip47WalletConnect.Nip47URINorm?): NwcInfoEvent? {
|
||||
uri ?: return null
|
||||
return withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips NWC-06 `metadata` from a request bound for a wallet that never said it
|
||||
* understands the field — [MetadataCarrying] has the reason that matters.
|
||||
*
|
||||
* APPLIED WHERE THE REQUEST IS BUILT rather than at each call site, so populating
|
||||
* `metadata` anywhere upstream is safe by construction.
|
||||
*
|
||||
* MUTATES the request in place — see the callers' KDoc. Requests are built per
|
||||
* send and not reused, and stripping a copy would mean rebuilding a params object
|
||||
* whose field list would then drift from the original.
|
||||
*/
|
||||
private fun Request.dropMetadataIfUnsupported(info: NwcInfoEvent?) {
|
||||
val carrier = metadataCarrier ?: return
|
||||
if (carrier.metadata == null || info?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true) return
|
||||
carrier.metadata = null
|
||||
}
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean = settings.nwcWallets.value.isNotEmpty()
|
||||
@@ -203,21 +249,30 @@ class NwcSignerState(
|
||||
*/
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onResponse)
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse)
|
||||
|
||||
/**
|
||||
* Sends a generic NIP-47 request to a specific wallet.
|
||||
*
|
||||
* [request] MAY BE MUTATED: NWC-06 `metadata` is stripped in place when the
|
||||
* wallet has not advertised support for it. Build a fresh request per send
|
||||
* rather than retaining or re-reading this one.
|
||||
*/
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm?,
|
||||
request: Request,
|
||||
onTimeout: () -> Unit = {},
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
val walletSigner = buildSigner(walletService) ?: signer
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
|
||||
val info = walletInfo(walletService)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
@@ -234,14 +289,7 @@ class NwcSignerState(
|
||||
// be missed.
|
||||
assembler.subscribeAndFlush(filter)
|
||||
|
||||
// Safety net: drop the filter after 60s if the wallet never replies.
|
||||
// The happy path (response arrives) cancels this job and unsubscribes
|
||||
// through assembler.unsubscribeSoon, which debounces.
|
||||
val timeoutJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(60000)
|
||||
assembler.unsubscribe(filter)
|
||||
}
|
||||
val timeoutJob = launchGiveUpTimer(assembler, filter, event.id, onTimeout)
|
||||
|
||||
val responseCache = NostrWalletConnectResponseCache(walletSigner)
|
||||
cache.consume(event, null, true, walletService.relayUri) {
|
||||
@@ -255,15 +303,30 @@ class NwcSignerState(
|
||||
|
||||
/**
|
||||
* Sends a zap payment request to the default wallet.
|
||||
*
|
||||
* [metadata] is NWC-06's per-payment blob and is dropped unless the wallet
|
||||
* advertises `06`; see [dropMetadataIfUnsupported].
|
||||
*/
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onTimeout: () -> Unit = {},
|
||||
metadata: Map<String, Any?>? = null,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
|
||||
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value, useNip44 = prefersNip44(walletService))
|
||||
val info = walletInfo(walletService)
|
||||
val request = PayInvoiceMethod.create(bolt11, metadata)
|
||||
request.dropMetadataIfUnsupported(info)
|
||||
|
||||
val event =
|
||||
LnZapPaymentRequestEvent.createRequest(
|
||||
request,
|
||||
walletService.pubKeyHex,
|
||||
nip47Signer.value,
|
||||
useNip44 = prefersNip44(info),
|
||||
)
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
@@ -278,14 +341,7 @@ class NwcSignerState(
|
||||
// See sendNwcRequestToWallet above for the rationale.
|
||||
assembler.subscribeAndFlush(filter)
|
||||
|
||||
// Safety net: drop the filter after 60s if the wallet never replies.
|
||||
// The happy path (response arrives) cancels this job and instead
|
||||
// hands off to assembler.unsubscribeSoon, which debounces.
|
||||
val timeoutJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits 1 minute to complete payment.
|
||||
assembler.unsubscribe(filter)
|
||||
}
|
||||
val timeoutJob = launchGiveUpTimer(assembler, filter, event.id, onTimeout)
|
||||
|
||||
cache.consume(event, zappedNote, true, walletService.relayUri) {
|
||||
timeoutJob.cancel()
|
||||
@@ -295,4 +351,59 @@ class NwcSignerState(
|
||||
|
||||
return Pair(event, walletService.relayUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety net for a wallet that never replies: drops the subscription filter and retires
|
||||
* the request. The happy path cancels this job and unsubscribes through
|
||||
* [NWCPaymentFilterAssembler.unsubscribeSoon] instead, which debounces.
|
||||
*/
|
||||
private fun launchGiveUpTimer(
|
||||
assembler: NWCPaymentFilterAssembler,
|
||||
filter: NWCPaymentQueryState,
|
||||
requestId: HexKey,
|
||||
onTimeout: () -> Unit,
|
||||
) = scope.launch(Dispatchers.IO) {
|
||||
delay(NWC_RESPONSE_TIMEOUT_MS)
|
||||
assembler.unsubscribe(filter)
|
||||
giveUpWaiting(requestId, onTimeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires a request whose response never arrived: removes the tracker entry so it
|
||||
* does not leak, and tells the caller so the user hears about it. A silent give-up
|
||||
* is the worst outcome for a payment UI — the action just appears not to have
|
||||
* happened, which is indistinguishable from a refusal the wallet did send.
|
||||
*
|
||||
* A `cleanup` that returns false means a response beat us to the tracker entry,
|
||||
* so the response path is already reporting and this must stay quiet.
|
||||
*/
|
||||
private fun giveUpWaiting(
|
||||
requestId: HexKey,
|
||||
onTimeout: () -> Unit,
|
||||
) {
|
||||
val wasStillPending = cache.paymentTracker.cleanup(requestId)
|
||||
if (wasStillPending) {
|
||||
Log.w("NwcSignerState") {
|
||||
"No NIP-47 response for request $requestId after ${NWC_RESPONSE_TIMEOUT_MS}ms; giving up and dropping the subscription."
|
||||
}
|
||||
onTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long a NIP-47 request waits for its kind-23195 reply before the client
|
||||
* gives up. Exposed in seconds so the UI can name the number it shows the user.
|
||||
*/
|
||||
const val NWC_RESPONSE_TIMEOUT_SECONDS = 60
|
||||
|
||||
const val NWC_RESPONSE_TIMEOUT_MS = NWC_RESPONSE_TIMEOUT_SECONDS * 1000L
|
||||
|
||||
/**
|
||||
* How long a request will wait for a cold info cache before falling back to
|
||||
* NIP-04. Comfortably over a healthy single-relay round trip, far under the
|
||||
* 30s the fetch itself would otherwise allow in front of a payment tap.
|
||||
*/
|
||||
const val NIP44_NEGOTIATION_WAIT_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
||||
+24
-6
@@ -58,7 +58,11 @@ class IndexerRelayListState(
|
||||
|
||||
fun indexListEvent(note: Note) = note.event as? IndexerRelayListEvent ?: settings.backupIndexRelayList
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
|
||||
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = indexListEvent(note) ?: return DefaultIndexerRelayList
|
||||
// Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet".
|
||||
return decryptionCache.relays(event)
|
||||
}
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
@@ -73,12 +77,26 @@ class IndexerRelayListState(
|
||||
*/
|
||||
fun normalizeIndexerRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
|
||||
|
||||
/** See `Nip65RelayListState.assumedDefaults`. Empty as soon as any kind:10086 exists. */
|
||||
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (indexListEvent(note) == null) DefaultIndexerRelayList else emptySet()
|
||||
|
||||
val assumedDefaultsFlow =
|
||||
getIndexerRelayListFlow()
|
||||
.map { assumedDefaults(it.note) }
|
||||
.onStart { emit(assumedDefaults(indexerListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
assumedDefaults(indexerListNote),
|
||||
)
|
||||
|
||||
/**
|
||||
* The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup]
|
||||
* substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the
|
||||
* one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read
|
||||
* this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff
|
||||
* what the user actually configured.
|
||||
* The account's indexer relays. [normalizeIndexerRelayListWithBackup] substitutes
|
||||
* [DefaultIndexerRelayList] when there is no kind:10086 at all — but **not** when the one we
|
||||
* have decodes to zero relays, which is the user saying "no indexers" and is honored. Callers
|
||||
* assembling metadata / relay-list REQs must therefore tolerate an empty set; use
|
||||
* [flowNoDefaults] to show or diff what the user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same
|
||||
* reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an
|
||||
|
||||
+13
-1
@@ -71,7 +71,15 @@ class FollowListsState(
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex)
|
||||
// Hides shells that a kind-5 deletion event from the list's author has already
|
||||
// deleted (e.g. a persisted TopFilter re-creates an empty shell for the deleted
|
||||
// address after a restart, and its name falls back to the dTag/UUID). Shells that
|
||||
// are merely not loaded yet stay in the list so the UI can subscribe and fetch
|
||||
// them from relays.
|
||||
fun existingPeopleListNotes() =
|
||||
cache.addressables
|
||||
.filter(FollowListEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
|
||||
|
||||
val followListVersions = MutableStateFlow(0)
|
||||
|
||||
@@ -255,6 +263,10 @@ class FollowListsState(
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
// Any screen whose persisted feed filter still points at this follow pack would
|
||||
// keep re-creating an empty shell for its address (and render the dTag/UUID in
|
||||
// the top bar) — reset those filters to their default.
|
||||
account.settings.resetFeedFiltersPointingTo(followListEvent.address())
|
||||
}
|
||||
|
||||
suspend fun addUserToSet(
|
||||
|
||||
+11
@@ -69,10 +69,17 @@ class PeopleListsState(
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
// Hides the fixed-dTag block-list shell when it is not loaded (it has no
|
||||
// meaningful name until it exists) and shells that a kind-5 deletion event from
|
||||
// the list's author has already deleted (e.g. a persisted TopFilter re-creates an
|
||||
// empty shell for the deleted address after a restart, and its name falls back to
|
||||
// the dTag/UUID). Shells that are merely not loaded yet stay in the list so the UI
|
||||
// can subscribe and fetch them from relays.
|
||||
fun existingPeopleListNotes() =
|
||||
cache.addressables
|
||||
.filter(PeopleListEvent.KIND, user.pubkeyHex)
|
||||
.filter { it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG || it.event != null }
|
||||
.filter { it.event != null || !cache.hasBeenDeleted(it.address) }
|
||||
|
||||
val peopleListVersions = MutableStateFlow(0)
|
||||
|
||||
@@ -262,6 +269,10 @@ class PeopleListsState(
|
||||
val followListEvent = getPeopleList(identifierTag)
|
||||
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
// Any screen whose persisted feed filter still points at this list would keep
|
||||
// re-creating an empty shell for its address (and render the dTag/UUID in the
|
||||
// top bar) — reset those filters to their default.
|
||||
account.settings.resetFeedFiltersPointingTo(followListEvent.address())
|
||||
}
|
||||
|
||||
suspend fun addUserToSet(
|
||||
|
||||
+24
-5
@@ -58,7 +58,11 @@ class SearchRelayListState(
|
||||
|
||||
fun searchListEvent(note: Note) = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList
|
||||
|
||||
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
|
||||
suspend fun normalizeSearchRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = searchListEvent(note) ?: return DefaultSearchRelayList
|
||||
// Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet".
|
||||
return decryptionCache.relays(event)
|
||||
}
|
||||
|
||||
suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
@@ -74,16 +78,31 @@ class SearchRelayListState(
|
||||
*/
|
||||
fun normalizeSearchRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
|
||||
|
||||
/** See `Nip65RelayListState.assumedDefaults`. Empty as soon as any kind:10007 exists. */
|
||||
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (searchListEvent(note) == null) DefaultSearchRelayList else emptySet()
|
||||
|
||||
val assumedDefaultsFlow =
|
||||
getSearchRelayListFlow()
|
||||
.map { assumedDefaults(it.note) }
|
||||
.onStart { emit(assumedDefaults(searchListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
assumedDefaults(searchListNote),
|
||||
)
|
||||
|
||||
/**
|
||||
* The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup]
|
||||
* substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the
|
||||
* one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can
|
||||
* The account's search relays. [normalizeSearchRelayListWithBackup] substitutes
|
||||
* [DefaultSearchRelayList] when there is no kind:10007 at all — but **not** when the one we
|
||||
* have decodes to zero relays, which is the user saying "no search relays" and is honored.
|
||||
* Callers assembling NIP-50 REQs must tolerate an empty set, and can
|
||||
* rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the
|
||||
* user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means
|
||||
* the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed
|
||||
* left a window where `.value` contradicted the "never empty" contract above and search
|
||||
* left a window where `.value` reported nothing before the event had been read at all, so search
|
||||
* silently queried nothing. That window is unbounded for a NIP-46 signer whose list has
|
||||
* private entries, since the first emission waits on a remote decrypt.
|
||||
*/
|
||||
|
||||
+24
-2
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model.nip65RelayList
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.relayListOrDefaultsWhenUnknown
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -58,9 +59,9 @@ class Nip65RelayListState(
|
||||
|
||||
fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList
|
||||
|
||||
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays
|
||||
fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.eventFinderRelays) { it.writeRelaysNorm()?.toSet() }
|
||||
|
||||
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.readRelaysNorm()?.toSet() ?: Constants.bootstrapInbox
|
||||
fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.bootstrapInbox) { it.readRelaysNorm()?.toSet() }
|
||||
|
||||
fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet()
|
||||
|
||||
@@ -70,6 +71,27 @@ class Nip65RelayListState(
|
||||
|
||||
fun normalizeNIP65AllRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = nip65Event(note)?.relays()?.map { it.relayUrl }?.toSet() ?: emptySet()
|
||||
|
||||
/**
|
||||
* The app defaults currently standing in for a user we have no kind:10002 for — empty as soon
|
||||
* as one exists, including an empty one.
|
||||
*
|
||||
* Uses the same `nip65Event(note) == null` predicate the substitution itself uses, so the two
|
||||
* cannot drift: whatever is listed here is exactly what the app is guessing on the user's
|
||||
* behalf. See [relayListOrDefaultsWhenUnknown].
|
||||
*/
|
||||
fun assumedDefaults(note: Note): Set<NormalizedRelayUrl> = if (nip65Event(note) == null) Constants.bootstrapInbox + Constants.eventFinderRelays else emptySet()
|
||||
|
||||
val assumedDefaultsFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { assumedDefaults(it.note) }
|
||||
.onStart { emit(assumedDefaults(nip65ListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
assumedDefaults(nip65ListNote),
|
||||
)
|
||||
|
||||
val outboxFlow =
|
||||
getNIP65RelayListFlow()
|
||||
.map { normalizeNIP65WriteRelayListWithBackup(it.note) }
|
||||
|
||||
+27
-1
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.awaitCreatedAtToSupersede
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -33,6 +34,8 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class AppSpecificState(
|
||||
@@ -52,12 +55,35 @@ class AppSpecificState(
|
||||
|
||||
fun getAppSpecificDataFlow(): StateFlow<NoteState> = amethystSettingsNote.flow().metadata.stateFlow
|
||||
|
||||
/**
|
||||
* Serializes the state snapshot and the timestamp it is stamped with. Two rapid toggles publish
|
||||
* from separate coroutines on the signer's dispatcher; without this they could read the same
|
||||
* previous timestamp and collide again, or take timestamps in the opposite order to the state
|
||||
* they captured. Signing and encrypting stay outside the lock — those can wait on an external
|
||||
* signer, and they don't affect ordering.
|
||||
*/
|
||||
private val stampOrder = Mutex()
|
||||
|
||||
/**
|
||||
* The newest version this instance has published, which is not always in [amethystSettingsNote]
|
||||
* yet: the cache is only updated once the event comes back through the broadcaster.
|
||||
*/
|
||||
private var lastPublishedAt = 0L
|
||||
|
||||
suspend fun saveNewAppSpecificData(): AppSpecificDataEvent {
|
||||
val toInternal = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
|
||||
val (toInternal, createdAt) =
|
||||
stampOrder.withLock {
|
||||
val snapshot = settings.syncedSettings.toInternal(settings.mutedPublicChats.value)
|
||||
val stamp = awaitCreatedAtToSupersede(maxOf(lastPublishedAt, amethystSettingsNote.event?.createdAt ?: 0L))
|
||||
lastPublishedAt = stamp
|
||||
snapshot to stamp
|
||||
}
|
||||
|
||||
return signer.sign(
|
||||
AppSpecificDataEvent.build(
|
||||
dTag = APP_SPECIFIC_DATA_D_TAG,
|
||||
description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey),
|
||||
createdAt = createdAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+7
-7
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.awaitCreatedAtToSupersede
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -31,7 +32,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.PlatformType
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -85,15 +85,15 @@ class AppRecommendationsState(
|
||||
private val publishMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Returns a createdAt strictly greater than whatever AppRecommendationEvent
|
||||
* currently sits in cache for this d-tag. Needed because
|
||||
* LocalCache.consumeBaseReplaceable drops updates whose createdAt isn't
|
||||
* strictly greater, and TimeUtils.now() has only second resolution.
|
||||
* The createdAt this d-tag's next version needs to supersede whatever is in cache for it. Waits
|
||||
* out the second rather than stamping the future, so repeatedly toggling one recommendation
|
||||
* cannot drift its `created_at` ahead of the clock. Runs under [publishMutex], which is what
|
||||
* keeps two waits from racing each other onto the same second.
|
||||
*/
|
||||
private fun nextCreatedAt(supportedKind: String): Long {
|
||||
private suspend fun nextCreatedAt(supportedKind: String): Long {
|
||||
val address = Address(AppRecommendationEvent.KIND, signer.pubKey, supportedKind)
|
||||
val latest = cache.getAddressableNoteIfExists(address)?.event?.createdAt ?: 0L
|
||||
return maxOf(TimeUtils.now(), latest + 1)
|
||||
return awaitCreatedAtToSupersede(latest)
|
||||
}
|
||||
|
||||
private fun currentRecommendations(supportedKind: String): List<RecommendationTag> {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.model.preferences
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Device-global persistence for the drawer section headings the user has collapsed, so the side menu
|
||||
* comes back folded the way they left it instead of springing fully open on every launch. Which
|
||||
* headings are folded is a per-device view choice, so unlike the hidden rows beside it in the drawer
|
||||
* it is never published to relays.
|
||||
*
|
||||
* Mirrors [RelayGroupDeletionPreferences]: app-wide (not per-account), loads the saved names on
|
||||
* construction, then writes every later change back. Takes the [DataStore] rather than a `Context`
|
||||
* so the whole cycle is exercised by a plain unit test against a temp file.
|
||||
*
|
||||
* Construct once, eagerly. The restore is a fire-and-forget coroutine, not a barrier, so the flow
|
||||
* reads as "nothing collapsed" until it lands; building this at app startup rather than on first use
|
||||
* puts that read many frames ahead of the drawer's first composition (and the store's file has
|
||||
* already been parsed by then, for `UiSharedPreferences`). Worst case if it ever lost that race is
|
||||
* cosmetic — a heading renders open and then folds — which is why no one waits on it.
|
||||
*/
|
||||
@Stable
|
||||
class DrawerSectionCollapsePreferences(
|
||||
private val store: DataStore<Preferences>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
private val collapsed = MutableStateFlow<Set<DrawerSectionId>>(emptySet())
|
||||
|
||||
/** The collapsed headings; the drawer collects this to decide which sections render their rows. */
|
||||
val flow: StateFlow<Set<DrawerSectionId>> = collapsed.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
restoreFromDisk()
|
||||
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
|
||||
collapsed.drop(1).collect { persist(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapses [section] if expanded, expands it if collapsed. Safe to call from the main thread. */
|
||||
fun toggle(section: DrawerSectionId) = collapsed.update { if (section in it) it - section else it + section }
|
||||
|
||||
private suspend fun restoreFromDisk() {
|
||||
try {
|
||||
val raw = store.data.first()[KEY] ?: return
|
||||
collapsed.value = drawerSectionIdsFromNames(raw)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("DrawerSectionCollapsePrefs") { "Error reading collapsed drawer sections: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(sections: Set<DrawerSectionId>) {
|
||||
try {
|
||||
store.edit { prefs -> prefs[KEY] = sections.toNames() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("DrawerSectionCollapsePrefs") { "Error writing collapsed drawer sections: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY = stringSetPreferencesKey("ui.drawer.collapsedSections")
|
||||
}
|
||||
}
|
||||
+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.model.serverList
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* The relays the app is **guessing** on the user's behalf because it has not seen their lists yet.
|
||||
*
|
||||
* Non-empty only while the corresponding event is absent — never because a list is empty, which is
|
||||
* a choice we honor (see `relayListOrDefaultsWhenUnknown`). It therefore empties itself, per list,
|
||||
* the moment the user's own data lands; no window, no timeout, no bookkeeping.
|
||||
*
|
||||
* **Deliberately NOT merged into [TrustedRelayListsState].** That one feeds `Account.isInMyRelayList`
|
||||
* -> `RelayAuthPermissionLedger` -> `RelayAuthResolver`, i.e. the NIP-42 AUTH decision. Guessed
|
||||
* relays must never make the app sign an AUTH challenge as though they were the user's own — that
|
||||
* would turn a timing signal into a signed identity assertion. The single consumer of this flow is
|
||||
* Tor routing.
|
||||
*/
|
||||
class AssumedRelayListsState(
|
||||
val nip65RelayList: Nip65RelayListState,
|
||||
val searchRelayList: SearchRelayListState,
|
||||
val indexerRelayList: IndexerRelayListState,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
val flow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(
|
||||
nip65RelayList.assumedDefaultsFlow,
|
||||
searchRelayList.assumedDefaultsFlow,
|
||||
indexerRelayList.assumedDefaultsFlow,
|
||||
) { nip65, search, indexer ->
|
||||
nip65 + search + indexer
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
kotlinx.coroutines.flow.SharingStarted.Eagerly,
|
||||
nip65RelayList.assumedDefaultsFlow.value +
|
||||
searchRelayList.assumedDefaultsFlow.value +
|
||||
indexerRelayList.assumedDefaultsFlow.value,
|
||||
)
|
||||
}
|
||||
+114
-91
@@ -20,14 +20,17 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
@@ -35,112 +38,132 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
/**
|
||||
* Pushes the relay classifications [TorRelayState] needs — which relays are DM, trusted, guessed, or
|
||||
* money-operation relays — as a union across every logged-in account.
|
||||
*
|
||||
* All four are the same fold: pick one set per account, union them, publish. It used to be written
|
||||
* out four times at ~30 lines each, and the copies had already drifted apart in trivial ways (an
|
||||
* `if (isEmpty)` guard that could never fire, differently-named accumulators). Sharing one
|
||||
* implementation is what keeps a fifth classification from being another 30 lines of the same
|
||||
* thing — and, more importantly, from being 30 lines that quietly forget a step.
|
||||
*/
|
||||
class AccountsTorStateConnector(
|
||||
accountsCache: AccountCacheState,
|
||||
torEvaluatorFlow: TorRelayState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
val allDmRelayFlows: Flow<Set<NormalizedRelayUrl>> =
|
||||
accountsCache.accounts
|
||||
.debounce(200)
|
||||
.transformLatest { snapshot ->
|
||||
val dmFlows = snapshot.map { it.value.dmRelayList.flow }
|
||||
|
||||
val dmFlowReady =
|
||||
dmFlows.ifEmpty {
|
||||
listOf(MutableStateFlow(emptySet()))
|
||||
}
|
||||
|
||||
if (dmFlowReady.isEmpty()) {
|
||||
emit(emptySet())
|
||||
} else {
|
||||
emitAll(
|
||||
combine(dmFlowReady) {
|
||||
val dmRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
it.forEach {
|
||||
dmRelays.addAll(it)
|
||||
}
|
||||
dmRelays.toSet()
|
||||
},
|
||||
)
|
||||
}
|
||||
}.onEach {
|
||||
torEvaluatorFlow.dmRelays.tryEmit(it)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Union of one relay set across all logged-in accounts, republished into [TorRelayState].
|
||||
*
|
||||
* `debounce(200)` rides out the burst of account churn at login; `transformLatest` drops the
|
||||
* previous fan-in when the account set changes so a logged-out account cannot keep contributing.
|
||||
* The seed is `emptySet()` for every classification: before any account exists, nothing is
|
||||
* classified.
|
||||
*
|
||||
* Takes its collaborators as parameters rather than reading constructor properties because the
|
||||
* call sites are property initializers, where non-`val` constructor parameters are in scope but
|
||||
* member functions cannot see them.
|
||||
*/
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
val allTrustedRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
|
||||
private fun unionAcrossAccounts(
|
||||
accountsCache: AccountCacheState,
|
||||
scope: CoroutineScope,
|
||||
select: (Account) -> Flow<Set<NormalizedRelayUrl>>,
|
||||
publish: (Set<NormalizedRelayUrl>) -> Unit,
|
||||
): StateFlow<Set<NormalizedRelayUrl>> =
|
||||
accountsCache.accounts
|
||||
.debounce(200)
|
||||
.transformLatest { snapshot ->
|
||||
val trustedRelayFlows = snapshot.map { it.value.trustedRelays.flow }
|
||||
|
||||
val trustedRelayFlowReady =
|
||||
trustedRelayFlows.ifEmpty {
|
||||
listOf(MutableStateFlow(emptySet()))
|
||||
}
|
||||
|
||||
if (trustedRelayFlowReady.isEmpty()) {
|
||||
emit(emptySet())
|
||||
} else {
|
||||
emitAll(
|
||||
combine(trustedRelayFlowReady) {
|
||||
val trustedRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
it.forEach {
|
||||
trustedRelays.addAll(it)
|
||||
}
|
||||
trustedRelays.toSet()
|
||||
},
|
||||
)
|
||||
}
|
||||
}.onEach {
|
||||
torEvaluatorFlow.trustedRelays.tryEmit(it)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
// Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK
|
||||
// Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the
|
||||
// money-operations Tor preference instead of being classified as generic "new" relays.
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
val allMoneyOpRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
|
||||
accountsCache.accounts
|
||||
.debounce(200)
|
||||
.transformLatest { snapshot ->
|
||||
val perAccountFlows =
|
||||
snapshot.map { (_, account) ->
|
||||
combine(
|
||||
account.settings.nwcWallets,
|
||||
account.settings.clinkDebitWallets,
|
||||
) { nwcWallets, clinkDebitWallets ->
|
||||
val relays = mutableSetOf<NormalizedRelayUrl>()
|
||||
nwcWallets.forEach { relays.add(it.uri.relayUri) }
|
||||
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
|
||||
relays.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) }
|
||||
val perAccount =
|
||||
snapshot
|
||||
.map { select(it.value) }
|
||||
.ifEmpty { listOf(MutableStateFlow(emptySet())) }
|
||||
|
||||
emitAll(
|
||||
combine(ready) { perAccount ->
|
||||
val moneyOpRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
perAccount.forEach { moneyOpRelays.addAll(it) }
|
||||
moneyOpRelays.toSet()
|
||||
combine(perAccount) { sets ->
|
||||
sets.flatMapTo(mutableSetOf()) { it }
|
||||
},
|
||||
)
|
||||
}.onEach {
|
||||
torEvaluatorFlow.moneyOpRelays.tryEmit(it)
|
||||
}.stateIn(
|
||||
}.onEach(publish)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
/** NIP-17 DM relays: these follow the dedicated DM preference, never the generic "new" one. */
|
||||
val allDmRelayFlows: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { it.dmRelayList.flow },
|
||||
publish = { torEvaluatorFlow.dmRelays.tryEmit(it) },
|
||||
)
|
||||
|
||||
/** Everything the user actually put in one of their own relay lists. */
|
||||
val allTrustedRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { it.trustedRelays.flow },
|
||||
publish = { torEvaluatorFlow.trustedRelays.tryEmit(it) },
|
||||
)
|
||||
|
||||
/**
|
||||
* Relays the app is *guessing* while an account's own lists are unknown. Feeds
|
||||
* [TorRelayState.assumedRelays] and nothing else — see `AssumedRelayListsState` for why these
|
||||
* must never reach the AUTH decision.
|
||||
*
|
||||
* Per account, so a second login cannot re-open the guess for an established one; each
|
||||
* account's contribution empties itself as soon as that account's own lists land.
|
||||
*/
|
||||
val allAssumedRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { it.assumedRelays.flow },
|
||||
publish = {
|
||||
logHandover(it)
|
||||
torEvaluatorFlow.assumedRelays.tryEmit(it)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Persistent money-operation relays: NIP-47 wallet relays and saved CLINK Debits service
|
||||
* relays, so these connections honor the money-operations preference rather than being
|
||||
* classified as generic "new" relays.
|
||||
*/
|
||||
val allMoneyOpRelaysFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
unionAcrossAccounts(
|
||||
accountsCache,
|
||||
scope,
|
||||
select = { account ->
|
||||
combine(
|
||||
account.settings.nwcWallets,
|
||||
account.settings.clinkDebitWallets,
|
||||
) { nwcWallets, clinkDebitWallets ->
|
||||
val relays = mutableSetOf<NormalizedRelayUrl>()
|
||||
nwcWallets.forEach { relays.add(it.uri.relayUri) }
|
||||
clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) }
|
||||
relays.toSet()
|
||||
}
|
||||
},
|
||||
publish = { torEvaluatorFlow.moneyOpRelays.tryEmit(it) },
|
||||
)
|
||||
|
||||
@Volatile private var lastAssumedCount: Int = -1
|
||||
|
||||
/**
|
||||
* The handover is the whole contract of the guessed-relay feature: the moment a user's own
|
||||
* lists arrive, every relay we were guessing about goes back to the policy they actually asked
|
||||
* for. Logged at INFO because "did it hand over, and when" is not answerable from any other
|
||||
* line — the reconnect that follows looks identical to an ordinary one.
|
||||
*/
|
||||
private fun logHandover(relays: Set<NormalizedRelayUrl>) {
|
||||
if (relays.size == lastAssumedCount) return
|
||||
val released = if (relays.isEmpty()) " (own lists arrived; released to their real Tor policy)" else ""
|
||||
Log.i("AccountsTorState") { "Guessed relays: $lastAssumedCount -> ${relays.size}$released" }
|
||||
lastAssumedCount = relays.size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,5 @@ package com.vitorpamplona.amethyst.model.torState
|
||||
|
||||
// Canonical type now lives in commons
|
||||
typealias TorRelayEvaluation = com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
|
||||
|
||||
typealias RelayClassification = com.vitorpamplona.amethyst.commons.tor.RelayClassification
|
||||
|
||||
@@ -46,6 +46,13 @@ class TorRelayState(
|
||||
val dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/**
|
||||
* Relays guessed on the user's behalf while their own lists are unknown. Fed by
|
||||
* [AccountsTorStateConnector]; see `AssumedRelayListsState` for why this is separate from
|
||||
* [trustedRelays] rather than merged into it.
|
||||
*/
|
||||
val assumedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
|
||||
|
||||
/**
|
||||
* Relays known to be used for money operations from persistent configuration: NIP-47 wallet
|
||||
* relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all
|
||||
@@ -130,47 +137,49 @@ class TorRelayState(
|
||||
currentSettings(),
|
||||
)
|
||||
|
||||
val flow =
|
||||
combineTransform(
|
||||
torSettings,
|
||||
private fun currentClassification() =
|
||||
RelayClassification(
|
||||
trusted = trustedRelays.value,
|
||||
dm = dmRelays.value,
|
||||
moneyOp = currentMoneyOpRelays(),
|
||||
assumed = assumedRelays.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* The four category sets as one value. Folding them here also keeps the evaluation flow below
|
||||
* at two sources instead of six — `combineTransform`'s typed overloads stop at five.
|
||||
*/
|
||||
private val classification =
|
||||
combine(
|
||||
trustedRelays,
|
||||
dmRelays,
|
||||
moneyOpRelays,
|
||||
adHocMoneyOpCounts,
|
||||
) {
|
||||
torSettings: TorRelaySettings,
|
||||
trustedRelayList: Set<NormalizedRelayUrl>,
|
||||
dmRelayList: Set<NormalizedRelayUrl>,
|
||||
moneyOpRelayList: Set<NormalizedRelayUrl>,
|
||||
adHocMoneyOps: Map<NormalizedRelayUrl, Int>,
|
||||
->
|
||||
emit(
|
||||
TorRelayEvaluation(
|
||||
torSettings = torSettings,
|
||||
trustedRelayList = trustedRelayList,
|
||||
dmRelayList = dmRelayList,
|
||||
moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys,
|
||||
),
|
||||
assumedRelays,
|
||||
) { trusted, dm, moneyOp, adHocMoneyOps, assumed ->
|
||||
RelayClassification(
|
||||
trusted = trusted,
|
||||
dm = dm,
|
||||
moneyOp = moneyOp + adHocMoneyOps.keys,
|
||||
assumed = assumed,
|
||||
)
|
||||
}
|
||||
|
||||
val flow =
|
||||
combineTransform(
|
||||
torSettings,
|
||||
classification,
|
||||
) { torSettings: TorRelaySettings, classification: RelayClassification ->
|
||||
emit(TorRelayEvaluation(torSettings, classification))
|
||||
}.onStart {
|
||||
emit(
|
||||
TorRelayEvaluation(
|
||||
torSettings = torSettings.value,
|
||||
trustedRelayList = trustedRelays.value,
|
||||
dmRelayList = dmRelays.value,
|
||||
moneyOpRelayList = currentMoneyOpRelays(),
|
||||
),
|
||||
TorRelayEvaluation(torSettings.value, currentClassification()),
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
TorRelayEvaluation(
|
||||
torSettings = torSettings.value,
|
||||
trustedRelayList = trustedRelays.value,
|
||||
dmRelayList = dmRelays.value,
|
||||
moneyOpRelayList = currentMoneyOpRelays(),
|
||||
),
|
||||
TorRelayEvaluation(torSettings.value, currentClassification()),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -178,13 +187,7 @@ class TorRelayState(
|
||||
* snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the
|
||||
* very next connection attempt, with no dependency on the combine pipeline having propagated yet.
|
||||
*/
|
||||
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) =
|
||||
TorRelayEvaluation(
|
||||
torSettings = currentSettings(),
|
||||
trustedRelayList = trustedRelays.value,
|
||||
dmRelayList = dmRelays.value,
|
||||
moneyOpRelayList = currentMoneyOpRelays(),
|
||||
).useTor(relay)
|
||||
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = TorRelayEvaluation(currentSettings(), currentClassification()).useTor(relay)
|
||||
|
||||
fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import android.os.Message
|
||||
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.connectedApps.signers.NostrSignerPermissionLedger
|
||||
@@ -50,6 +49,7 @@ import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -182,7 +182,7 @@ class NappletBrokerService : Service() {
|
||||
// could still spam distinct keys to keep the network up. Bound the damage: refuse new
|
||||
// lease keys past the cap. Real usage holds only a handful of foreground surfaces.
|
||||
if (firstReport && foregroundLeases.size >= MAX_FOREGROUND_LEASES) {
|
||||
Log.w("NappletBrokerService", "Foreground lease cap reached; ignoring new lease $token")
|
||||
Log.w("NappletBrokerService") { "Foreground lease cap reached; ignoring new lease $token" }
|
||||
return true
|
||||
}
|
||||
foregroundLeases[token] = SystemClock.elapsedRealtime()
|
||||
@@ -374,7 +374,7 @@ class NappletBrokerService : Service() {
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (now - entry.value > FOREGROUND_LEASE_TTL_MS) {
|
||||
Log.w("NappletBrokerService", "Foreground lease ${entry.key} expired (host process gone?); releasing hold")
|
||||
Log.w("NappletBrokerService") { "Foreground lease ${entry.key} expired (host process gone?); releasing hold" }
|
||||
iterator.remove()
|
||||
SandboxForegroundHold.release()
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ object SandboxForegroundHold {
|
||||
synchronized(this@SandboxForegroundHold) {
|
||||
// A surface may have re-acquired while we waited; only tear down if still released.
|
||||
if (holdCount == 0) {
|
||||
Log.d("SandboxForegroundHold", "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold")
|
||||
Log.d("SandboxForegroundHold") { "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold" }
|
||||
holdJob?.cancel()
|
||||
holdJob = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import com.vitorpamplona.amethyst.napplethost.WebFileChooserLauncher
|
||||
|
||||
/**
|
||||
* Invisible main-process host for one file pick made on behalf of an **embedded** WebView surface.
|
||||
*
|
||||
* The surface renders from the keyless `:napplet` process, which has no Activity to start a picker or
|
||||
* a permission prompt from, so [WebFileChooserCoordinator] launches this instead. It exists only long
|
||||
* enough to run the pick and report the result, and it reports on every exit — a chosen file, a
|
||||
* cancel, a system teardown — because the page's `<input type="file">` stays busy until it hears
|
||||
* something back.
|
||||
*/
|
||||
class WebFileChooserActivity : ComponentActivity() {
|
||||
private var token: String? = null
|
||||
private var reported = false
|
||||
|
||||
// Field, not a local: registerForActivityResult must run before this activity reaches STARTED.
|
||||
private val chooser =
|
||||
WebFileChooserLauncher(this) { uris ->
|
||||
report(uris?.map { it.toString() }?.toTypedArray())
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val token = intent.getStringExtra(WebFileChooserCoordinator.EXTRA_TOKEN)
|
||||
this.token = token
|
||||
val ask = token?.let { WebFileChooserCoordinator.pendingFor(it) }
|
||||
if (ask == null) {
|
||||
// No pending request under this token: the surface went away, or the process restarted and
|
||||
// the request died with it. Nothing to report to.
|
||||
reported = true
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// A recreated instance has lost the in-flight request that its result would be matched against
|
||||
// (configChanges keeps this rare — a system kill, not a rotation), and relaunching would stack a
|
||||
// second picker on the first. Release the page's input now rather than let it wait on a result
|
||||
// that can no longer be routed anywhere.
|
||||
if (savedInstanceState != null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
chooser.launch(
|
||||
acceptTypes = ask.acceptTypes,
|
||||
allowMultiple = ask.allowMultiple,
|
||||
captureEnabled = ask.captureEnabled,
|
||||
pageTitle = ask.pageTitle,
|
||||
)
|
||||
}
|
||||
|
||||
/** Fail-open toward the page: any unreported teardown still releases its file input. */
|
||||
override fun finish() {
|
||||
report(null)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* The system can destroy this host without ever calling [finish] — a low-memory kill while the
|
||||
* picker is on top. Without this the page's file input would wait on a result nobody is left to
|
||||
* send, dead for the life of the page, and the coordinator would hold the reply callback (and the
|
||||
* controller behind it) forever.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
chooser.teardown()
|
||||
report(null)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun report(uris: Array<String>?) {
|
||||
if (reported) return
|
||||
reported = true
|
||||
token?.let { WebFileChooserCoordinator.complete(it, uris) }
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Runs the system file picker on behalf of an **embedded** WebView surface.
|
||||
*
|
||||
* The two embedded providers ([NappletBrowserService][com.vitorpamplona.amethyst.napplethost.NappletBrowserService]
|
||||
* and [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]) host their
|
||||
* WebView in the keyless `:napplet` process as a windowless Service, so when a page taps
|
||||
* `<input type="file">` there is no Activity there to start a picker from. They send the *description*
|
||||
* of the request over Messenger instead; this holds it here in the main process and launches
|
||||
* [WebFileChooserActivity], which runs the picker (and the camera, and the CAMERA permission prompt
|
||||
* that a `capture` input needs) and reports back to the caller, which relays the URIs to the sandbox.
|
||||
*
|
||||
* Mirrors [NappletConsentCoordinator]: the pending request is keyed by a one-time token so the
|
||||
* throwaway Activity carries nothing but that token. Every request completes exactly once — a
|
||||
* dismissed picker resolves to null, which is what releases the page's file input.
|
||||
*
|
||||
* URI read grants are per-UID, so the `content://` URIs granted to this process are readable by the
|
||||
* WebView in `:napplet` without any re-granting.
|
||||
*/
|
||||
object WebFileChooserCoordinator {
|
||||
/** The request as it arrived from the sandbox, plus where to send the answer. */
|
||||
class Pending(
|
||||
val acceptTypes: List<String>,
|
||||
val allowMultiple: Boolean,
|
||||
val captureEnabled: Boolean,
|
||||
val pageTitle: String?,
|
||||
val onResult: (Array<String>?) -> Unit,
|
||||
)
|
||||
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
|
||||
/**
|
||||
* Shows a picker for [acceptTypes] and calls [onResult] with the picked URIs as strings, or null
|
||||
* when nothing was chosen. [onResult] always runs, including when no picker host could be started
|
||||
* at all — the page's file input is waiting on it.
|
||||
*/
|
||||
fun request(
|
||||
context: Context,
|
||||
acceptTypes: List<String>,
|
||||
allowMultiple: Boolean,
|
||||
captureEnabled: Boolean,
|
||||
pageTitle: String?,
|
||||
onResult: (Array<String>?) -> Unit,
|
||||
) {
|
||||
val token = UUID.randomUUID().toString()
|
||||
pending[token] = Pending(acceptTypes, allowMultiple, captureEnabled, pageTitle, onResult)
|
||||
|
||||
val launch =
|
||||
Intent(context, WebFileChooserActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_TOKEN, token)
|
||||
|
||||
runCatching { context.startActivity(launch) }
|
||||
.onFailure { e ->
|
||||
Log.w(TAG, "Could not start the file chooser host", e)
|
||||
complete(token, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [WebFileChooserActivity] to learn what to ask the user for. */
|
||||
fun pendingFor(token: String): Pending? = pending[token]
|
||||
|
||||
/** Called by [WebFileChooserActivity] with the outcome; null = nothing chosen. Resolves at most once. */
|
||||
fun complete(
|
||||
token: String,
|
||||
uris: Array<String>?,
|
||||
) {
|
||||
pending.remove(token)?.onResult?.invoke(uris)
|
||||
}
|
||||
|
||||
const val EXTRA_TOKEN = "web_file_chooser_token"
|
||||
|
||||
private const val TAG = "WebFileChooser"
|
||||
}
|
||||
+15
-15
@@ -46,7 +46,6 @@ import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -54,6 +53,7 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
@@ -112,15 +112,13 @@ class NappletResourceFetcher(
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.dns(
|
||||
Dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
.dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
},
|
||||
).addNetworkInterceptor { chain ->
|
||||
}
|
||||
}.addNetworkInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
@@ -193,7 +191,7 @@ class NappletResourceFetcher(
|
||||
is NProfile -> resolveReplaceable(0, entity.hex)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return NappletResource(event.toJson().encodeToByteArray(), "application/json")
|
||||
return NappletResource(event.toJson().encodeToByteArray(), MIME_JSON)
|
||||
}
|
||||
|
||||
/** A non-replaceable event by id: local cache first, then a bounded relay fetch. */
|
||||
@@ -300,7 +298,7 @@ class NappletResourceFetcher(
|
||||
meta
|
||||
.removeSuffix(";base64")
|
||||
.substringBefore(';')
|
||||
.ifEmpty { "text/plain" }
|
||||
.ifEmpty { MIME_PLAIN_TEXT }
|
||||
.lowercase()
|
||||
val bytes =
|
||||
if (isBase64) {
|
||||
@@ -323,8 +321,8 @@ class NappletResourceFetcher(
|
||||
val type =
|
||||
when {
|
||||
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
|
||||
declaredType == "application/json" && isJson(bytes) -> "application/json"
|
||||
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
|
||||
declaredType == MIME_JSON && isJson(bytes) -> MIME_JSON
|
||||
declaredType == MIME_PLAIN_TEXT && isPlainText(bytes) -> MIME_PLAIN_TEXT
|
||||
else -> null
|
||||
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
|
||||
return success(NappletResource(bytes, type))
|
||||
@@ -353,7 +351,7 @@ class NappletResourceFetcher(
|
||||
message: String? = null,
|
||||
): NappletResourceResult = NappletResourceResult.Failure(error, message)
|
||||
|
||||
private fun readBounded(input: java.io.InputStream): ByteArray? {
|
||||
private fun readBounded(input: InputStream): ByteArray? {
|
||||
input.use { source ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
@@ -417,6 +415,8 @@ class NappletResourceFetcher(
|
||||
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
|
||||
private const val ERROR_DECODE_FAILED = "decode-failed"
|
||||
private const val ERROR_NETWORK = "network-error"
|
||||
private const val MIME_JSON = "application/json"
|
||||
private const val MIME_PLAIN_TEXT = "text/plain"
|
||||
private val SHA256 = Regex("^[0-9a-f]{64}$")
|
||||
private val ALLOWED_SNIFFED_TYPES =
|
||||
setOf(
|
||||
@@ -432,5 +432,5 @@ class NappletResourceFetcher(
|
||||
|
||||
private class BlockedResourceException(
|
||||
message: String,
|
||||
) : java.io.IOException(message)
|
||||
) : IOException(message)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
|
||||
@@ -159,15 +160,17 @@ class V4VPaymentHandler(
|
||||
tlvRecords = tlvRecords,
|
||||
)
|
||||
|
||||
account.zaps.sendNwcRequest(request) { response: Response? ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response.errorMessage()
|
||||
?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
account.zaps.sendNwcRequest(
|
||||
request = request,
|
||||
onResponse = { response: Response? ->
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,15 +253,18 @@ class V4VPaymentHandler(
|
||||
is PaymentSource.Nwc -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
response.errorMessage()
|
||||
?: stringRes(context, R.string.error_parsing_error_message),
|
||||
)
|
||||
}
|
||||
}
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = zappedNote,
|
||||
onResponse = { response ->
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
|
||||
},
|
||||
)
|
||||
done++
|
||||
onProgress(done.toFloat() / payables.size)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
@@ -60,6 +62,11 @@ class ZapPaymentHandler(
|
||||
val info: MyZapSplitSetup,
|
||||
val amountMilliSats: Long,
|
||||
val invoice: String,
|
||||
// The signed kind 9734 this invoice was fetched with, and the message on it.
|
||||
// Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for
|
||||
// a NONZAP split, which has no zap request to send.
|
||||
val zapRequest: LnZapRequestEvent? = null,
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
data class UnverifiedZapSplitSetup(
|
||||
@@ -417,21 +424,30 @@ class ZapPaymentHandler(
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = note,
|
||||
// Dropped unless the wallet advertises NWC-06 — see NwcSignerState.
|
||||
metadata =
|
||||
NwcTransactionMetadata.build(
|
||||
zapRequest = payable.zapRequest,
|
||||
recipientIdentifier = payable.info.lnAddress,
|
||||
comment = payable.message,
|
||||
),
|
||||
onResponse = { response ->
|
||||
progress.step()
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
stringRes(context, R.string.wallet_connect_pay_invoice_error_error, detail),
|
||||
payable.info.user,
|
||||
)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
nwcTimeoutMessage(context),
|
||||
payable.info.user,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
progress.step()
|
||||
@@ -551,6 +567,10 @@ class ZapPaymentHandler(
|
||||
): Payable {
|
||||
var progressThisPayment = 0.00f
|
||||
|
||||
// Only the request the provider actually accepted may be claimed as bound to
|
||||
// this invoice; see lnAddressInvoice's onZapRequestSent.
|
||||
var sentZapRequest: LnZapRequestEvent? = null
|
||||
|
||||
val invoice =
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnAddress = lud16,
|
||||
@@ -564,6 +584,7 @@ class ZapPaymentHandler(
|
||||
onProgressStep(step)
|
||||
},
|
||||
context = context,
|
||||
onZapRequestSent = { sentZapRequest = it },
|
||||
)
|
||||
|
||||
onProgressStep(1 - progressThisPayment)
|
||||
@@ -572,6 +593,8 @@ class ZapPaymentHandler(
|
||||
info = splitSetup,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = invoice,
|
||||
zapRequest = sentZapRequest,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.ai
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* TODO: Remove before shipping. Debug-only mock for testing the AI writing help UI
|
||||
* on devices without Gemini Nano / AICore support.
|
||||
*/
|
||||
class MockWritingAssistant : WritingAssistant {
|
||||
override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Available
|
||||
|
||||
override suspend fun transform(
|
||||
text: String,
|
||||
tone: WritingTone,
|
||||
): WritingResult {
|
||||
delay(800)
|
||||
|
||||
val transformed =
|
||||
when (tone) {
|
||||
WritingTone.CORRECT -> {
|
||||
correctMock(text)
|
||||
}
|
||||
|
||||
WritingTone.REPHRASE -> {
|
||||
"Here's another way to put it: $text"
|
||||
}
|
||||
|
||||
WritingTone.SHORTER -> {
|
||||
text
|
||||
.split(".")
|
||||
.firstOrNull()
|
||||
?.trim()
|
||||
?.plus(".") ?: text
|
||||
}
|
||||
|
||||
WritingTone.ELABORATE -> {
|
||||
"$text Furthermore, this point deserves deeper consideration and nuance."
|
||||
}
|
||||
|
||||
WritingTone.FRIENDLY -> {
|
||||
"Hey! $text Hope that makes sense! :)"
|
||||
}
|
||||
|
||||
WritingTone.PROFESSIONAL -> {
|
||||
"I would like to bring to your attention the following: $text"
|
||||
}
|
||||
|
||||
WritingTone.MORE_DIRECT -> {
|
||||
text.replace("I think ", "").replace("maybe ", "").replace("perhaps ", "")
|
||||
}
|
||||
|
||||
WritingTone.PUNCHY -> {
|
||||
text.uppercase().replace(".", "!")
|
||||
}
|
||||
|
||||
WritingTone.EMOJIFY -> {
|
||||
"$text \uD83D\uDE80\uD83D\uDD25\u2728"
|
||||
}
|
||||
}
|
||||
|
||||
return WritingResult(
|
||||
originalText = text,
|
||||
transformedText = transformed,
|
||||
tone = tone,
|
||||
)
|
||||
}
|
||||
|
||||
private fun correctMock(text: String): String =
|
||||
text
|
||||
.replace("teh ", "the ")
|
||||
.replace("dont ", "don't ")
|
||||
.replace("cant ", "can't ")
|
||||
.replace("wont ", "won't ")
|
||||
.replace("i ", "I ")
|
||||
|
||||
override fun close() {
|
||||
// no-op: mock holds no native handles or background workers to release.
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,15 @@ import androidx.compose.runtime.Immutable
|
||||
interface WritingAssistant {
|
||||
suspend fun checkAvailability(): WritingAssistantStatus
|
||||
|
||||
/**
|
||||
* Asks the platform to fetch the on-device model when [checkAvailability] reported
|
||||
* [WritingAssistantStatus.Downloadable]. Returns the status after the attempt.
|
||||
*
|
||||
* Implementations must be safe to call repeatedly: only the first call per instance
|
||||
* starts a download, later ones just report the current status.
|
||||
*/
|
||||
suspend fun requestDownload(): WritingAssistantStatus
|
||||
|
||||
suspend fun transform(
|
||||
text: String,
|
||||
tone: WritingTone,
|
||||
@@ -40,8 +49,6 @@ enum class WritingTone {
|
||||
ELABORATE,
|
||||
FRIENDLY,
|
||||
PROFESSIONAL,
|
||||
MORE_DIRECT,
|
||||
PUNCHY,
|
||||
EMOJIFY,
|
||||
}
|
||||
|
||||
@@ -50,6 +57,9 @@ sealed class WritingAssistantStatus {
|
||||
|
||||
data object Unavailable : WritingAssistantStatus()
|
||||
|
||||
/** The device supports the model but it has not been fetched yet. */
|
||||
data object Downloadable : WritingAssistantStatus()
|
||||
|
||||
data object Downloading : WritingAssistantStatus()
|
||||
}
|
||||
|
||||
|
||||
+27
-1
@@ -53,6 +53,7 @@ class CallForegroundService : Service() {
|
||||
const val ACTION_UPDATE = "com.vitorpamplona.amethyst.CALL_UPDATE"
|
||||
const val EXTRA_PEER_NAME = "peer_name"
|
||||
const val EXTRA_IS_VIDEO = "is_video"
|
||||
const val EXTRA_IS_SCREEN_SHARING = "is_screen_sharing"
|
||||
const val EXTRA_STATUS_TEXT = "status_text"
|
||||
const val EXTRA_IS_RINGING = "is_ringing"
|
||||
private const val HANGUP_REQUEST_CODE = 0x70001
|
||||
@@ -75,6 +76,7 @@ class CallForegroundService : Service() {
|
||||
ACTION_START, ACTION_UPDATE -> {
|
||||
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
|
||||
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
|
||||
val isScreenSharing = intent.getBooleanExtra(EXTRA_IS_SCREEN_SHARING, false)
|
||||
val isRinging = intent.getBooleanExtra(EXTRA_IS_RINGING, false)
|
||||
val statusText = intent.getStringExtra(EXTRA_STATUS_TEXT)
|
||||
val notification = buildNotification(peerName, statusText)
|
||||
@@ -97,6 +99,9 @@ class CallForegroundService : Service() {
|
||||
if (isVideo && hasCameraPermission) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
}
|
||||
if (isScreenSharing) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
}
|
||||
type
|
||||
} else {
|
||||
@@ -108,7 +113,11 @@ class CallForegroundService : Service() {
|
||||
try {
|
||||
val fallbackType =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
||||
if (isScreenSharing) {
|
||||
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
type
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -142,6 +151,23 @@ class CallForegroundService : Service() {
|
||||
* it at 3 seconds as a safety net.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val removed = rootIntent?.component?.className
|
||||
Log.d(TAG) { "onTaskRemoved root=$removed" }
|
||||
|
||||
// Only the call's own task going away means the user dismissed the call. This callback
|
||||
// fires for *every* task of the app, and MainActivity lives in a separate one (it is
|
||||
// `singleInstance`, and CallActivity is launched with FLAG_ACTIVITY_NEW_TASK). Android
|
||||
// reclaims that backgrounded MainActivity task on its own while a call is running —
|
||||
// notably a few hundred ms after CallActivity enters picture-in-picture on HOME — and
|
||||
// hanging up on it ended calls the user never touched.
|
||||
//
|
||||
// A null root intent leaves the source unknown; treat it as a dismissal so a genuinely
|
||||
// swiped-away app can't leave a call running with no UI to end it.
|
||||
if (removed != null && removed != CallActivity::class.java.name) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
return
|
||||
}
|
||||
|
||||
publishHangupBlocking()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.call
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjection
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -65,9 +67,20 @@ class CallMediaManager(
|
||||
var localVideoTrack: VideoTrack? = null
|
||||
private set
|
||||
|
||||
var screenVideoSource: VideoSource? = null
|
||||
private set
|
||||
var screenVideoTrack: VideoTrack? = null
|
||||
private set
|
||||
|
||||
private var cameraCapturer: CameraVideoCapturer? = null
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var screenCapturer: ScreenShareCapturer? = null
|
||||
private var screenSurfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var usingFrontCamera: Boolean = true
|
||||
private var cameraWasEnabledBeforeScreenShare = false
|
||||
private var stoppingScreenShare = false
|
||||
|
||||
var onScreenShareEnded: (() -> Unit)? = null
|
||||
|
||||
private val _localVideoTrackFlow = MutableStateFlow<VideoTrack?>(null)
|
||||
val localVideoTrackFlow: StateFlow<VideoTrack?> = _localVideoTrackFlow.asStateFlow()
|
||||
@@ -75,6 +88,9 @@ class CallMediaManager(
|
||||
private val _isVideoEnabled = MutableStateFlow(false)
|
||||
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
|
||||
|
||||
private val _isScreenSharing = MutableStateFlow(false)
|
||||
val isScreenSharing: StateFlow<Boolean> = _isScreenSharing.asStateFlow()
|
||||
|
||||
private val _isFrontCamera = MutableStateFlow(true)
|
||||
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
|
||||
|
||||
@@ -146,6 +162,136 @@ class CallMediaManager(
|
||||
captureFps = fps
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts Android's system screen capture using the one-shot permission result supplied by
|
||||
* [android.media.projection.MediaProjectionManager]. The returned track is owned by this
|
||||
* manager and must be attached to the peer senders before [stopScreenShare] releases it.
|
||||
*/
|
||||
@Synchronized
|
||||
fun startScreenShare(permissionData: Intent): VideoTrack {
|
||||
screenVideoTrack?.let { return it }
|
||||
|
||||
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
|
||||
val egl = sharedEglBase ?: throw IllegalStateException("EGL context not initialized")
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
val captureSize = screenShareCaptureSize(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||
cameraWasEnabledBeforeScreenShare = _isVideoEnabled.value
|
||||
var source: VideoSource? = null
|
||||
var track: VideoTrack? = null
|
||||
var helper: SurfaceTextureHelper? = null
|
||||
var capturer: ScreenShareCapturer? = null
|
||||
|
||||
try {
|
||||
val createdSource = factory.createVideoSource(true)
|
||||
source = createdSource
|
||||
val createdTrack = factory.createVideoTrack("screen0", createdSource)
|
||||
track = createdTrack
|
||||
val createdHelper = SurfaceTextureHelper.create("ScreenCaptureThread", egl.eglBaseContext)
|
||||
helper = createdHelper
|
||||
val createdCapturer =
|
||||
ScreenShareCapturer(
|
||||
permissionData,
|
||||
object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
if (!stoppingScreenShare) {
|
||||
onScreenShareEnded?.invoke()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
capturer = createdCapturer
|
||||
|
||||
screenVideoSource = createdSource
|
||||
screenVideoTrack = createdTrack
|
||||
screenSurfaceTextureHelper = createdHelper
|
||||
screenCapturer = createdCapturer
|
||||
if (cameraWasEnabledBeforeScreenShare) {
|
||||
stopCamera()
|
||||
}
|
||||
createdCapturer.initialize(createdHelper, context, createdSource.capturerObserver)
|
||||
createdCapturer.startCapture(captureSize.width, captureSize.height, captureFps)
|
||||
_isScreenSharing.value = true
|
||||
_isVideoEnabled.value = true
|
||||
_localVideoTrackFlow.value = createdTrack
|
||||
return createdTrack
|
||||
} catch (e: Exception) {
|
||||
screenCapturer = null
|
||||
screenSurfaceTextureHelper = null
|
||||
screenVideoTrack = null
|
||||
screenVideoSource = null
|
||||
runCatching { capturer?.dispose() }
|
||||
runCatching { helper?.dispose() }
|
||||
runCatching { track?.dispose() }
|
||||
runCatching { source?.dispose() }
|
||||
if (cameraWasEnabledBeforeScreenShare) {
|
||||
_isVideoEnabled.value = true
|
||||
_localVideoTrackFlow.value = localVideoTrack
|
||||
startCamera()
|
||||
}
|
||||
cameraWasEnabledBeforeScreenShare = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops screen capture and restores the camera state from before sharing started. The
|
||||
* returned resources remain alive until the caller has replaced every [RtpSender] track.
|
||||
*
|
||||
* Pass `restoreCamera = false` when the whole session is going away — see [dispose]. Restoring
|
||||
* a camera that is about to be torn down opens the device for a few hundred milliseconds
|
||||
* (lighting the privacy indicator) and makes the following [stopCamera] block waiting for the
|
||||
* capture session it just started.
|
||||
*/
|
||||
@Synchronized
|
||||
fun stopScreenShare(restoreCamera: Boolean = true): ScreenShareResources? {
|
||||
val track = screenVideoTrack ?: return null
|
||||
val source = requireNotNull(screenVideoSource)
|
||||
stoppingScreenShare = true
|
||||
try {
|
||||
screenCapturer?.let { capturer ->
|
||||
runCatching { capturer.stopCapture() }
|
||||
runCatching { capturer.dispose() }
|
||||
}
|
||||
screenSurfaceTextureHelper?.let { runCatching { it.dispose() } }
|
||||
} finally {
|
||||
screenCapturer = null
|
||||
screenSurfaceTextureHelper = null
|
||||
screenVideoTrack = null
|
||||
screenVideoSource = null
|
||||
_isScreenSharing.value = false
|
||||
|
||||
if (restoreCamera && cameraWasEnabledBeforeScreenShare) {
|
||||
recreateCameraResources()
|
||||
_isVideoEnabled.value = true
|
||||
_localVideoTrackFlow.value = localVideoTrack
|
||||
startCamera()
|
||||
} else {
|
||||
_isVideoEnabled.value = false
|
||||
_localVideoTrackFlow.value = localVideoTrack
|
||||
}
|
||||
cameraWasEnabledBeforeScreenShare = false
|
||||
stoppingScreenShare = false
|
||||
}
|
||||
return ScreenShareResources(track, source)
|
||||
}
|
||||
|
||||
fun disposeScreenShareResources(resources: ScreenShareResources?) {
|
||||
resources ?: return
|
||||
runCatching { resources.track.dispose() }
|
||||
runCatching { resources.source.dispose() }
|
||||
}
|
||||
|
||||
private fun recreateCameraResources() {
|
||||
val factory = peerConnectionFactory ?: return
|
||||
// RtpSender.setTrack may release the previous native track wrapper, so restore a fresh
|
||||
// camera track before binding it back to the senders.
|
||||
runCatching { localVideoTrack?.dispose() }
|
||||
runCatching { localVideoSource?.dispose() }
|
||||
localVideoSource = factory.createVideoSource(false)
|
||||
localVideoTrack = factory.createVideoTrack("video0", localVideoSource)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startCamera() {
|
||||
if (cameraCapturer != null) return
|
||||
val source = localVideoSource ?: return
|
||||
@@ -186,6 +332,7 @@ class CallMediaManager(
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopCamera() {
|
||||
try {
|
||||
cameraCapturer?.stopCapture()
|
||||
@@ -215,6 +362,10 @@ class CallMediaManager(
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
// Everything below tears the camera down, so don't let the screen-share stop bring it back
|
||||
// up first — that opened the device mid-hangup and made stopCamera() wait on it.
|
||||
val screenResources = stopScreenShare(restoreCamera = false)
|
||||
disposeScreenShareResources(screenResources)
|
||||
try {
|
||||
stopCamera()
|
||||
} catch (e: Exception) {
|
||||
|
||||
+35
-5
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.call
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
@@ -34,33 +36,61 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
* whose lifetime is tied to the Activity's lifecycle.
|
||||
*/
|
||||
object CallSessionBridge {
|
||||
/** Account-scoped: survives MainActivity being destroyed mid-call. */
|
||||
var callManager: CallManager? = null
|
||||
private set
|
||||
|
||||
/** Account-scoped: everything [CallActivity] needs (signer, settings, signaling publish). */
|
||||
var account: Account? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Activity-scoped, and therefore nullable at any time: MainActivity can be destroyed while a
|
||||
* call is still running. Only consumers that genuinely need ViewModel-level helpers should
|
||||
* read this, and they must tolerate null.
|
||||
*/
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
private set
|
||||
|
||||
fun set(
|
||||
callManager: CallManager,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
this.callManager = callManager
|
||||
this.account = account
|
||||
this.accountViewModel = accountViewModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets call state and clears all references. Called from
|
||||
* [AccountViewModel.onCleared] during logout or account switch.
|
||||
* Drops only the Activity-scoped [accountViewModel] reference. Called from
|
||||
* [AccountViewModel.onCleared], which fires on every MainActivity destruction — including
|
||||
* while a call is in progress — so it must leave [callManager] and [account] intact.
|
||||
*
|
||||
* While a call is up the reference is kept: [CallActivity] still renders its UI from this
|
||||
* ViewModel and holds a strong reference to it either way, so clearing here would free
|
||||
* nothing and would only break the running call's UI. It is replaced wholesale by [set] as
|
||||
* soon as MainActivity comes back, and dropped by [clear] on logout / account switch.
|
||||
*/
|
||||
fun clearViewModel() {
|
||||
if (callManager?.state?.value !is CallState.Idle) return
|
||||
accountViewModel = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current call and clears all references. Called on a real logout or account switch
|
||||
* from `AccountSessionManager`, alongside `NestBridge.clear()`.
|
||||
*
|
||||
* Uses [CallManager.reset] (non-blocking, no mutex) instead of
|
||||
* [CallManager.hangup] to avoid deadlocking on `stateMutex` if
|
||||
* a cancelled coroutine on the dying `viewModelScope` still holds
|
||||
* it. Hangup signaling to the remote peer is the responsibility
|
||||
* of [CallActivity.onDestroy] and [CallForegroundService], not
|
||||
* a cancelled coroutine still holds it. Hangup signaling to the remote peer is the
|
||||
* responsibility of [CallActivity.onDestroy] and [CallForegroundService], not
|
||||
* the bridge teardown.
|
||||
*/
|
||||
fun clear() {
|
||||
callManager?.reset()
|
||||
callManager = null
|
||||
account = null
|
||||
accountViewModel = null
|
||||
}
|
||||
}
|
||||
|
||||
+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.call
|
||||
|
||||
import org.webrtc.VideoSource
|
||||
import org.webrtc.VideoTrack
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class ScreenShareCaptureSize(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
internal fun screenShareCaptureSize(
|
||||
widthPixels: Int,
|
||||
heightPixels: Int,
|
||||
maxDimension: Int = 1920,
|
||||
): ScreenShareCaptureSize {
|
||||
val width = widthPixels.coerceAtLeast(2)
|
||||
val height = heightPixels.coerceAtLeast(2)
|
||||
val scale = min(1f, maxDimension.toFloat() / maxOf(width, height))
|
||||
|
||||
fun even(value: Int): Int = value.coerceAtLeast(2).let { it - it % 2 }
|
||||
|
||||
return ScreenShareCaptureSize(
|
||||
width = even((width * scale).roundToInt()),
|
||||
height = even((height * scale).roundToInt()),
|
||||
)
|
||||
}
|
||||
|
||||
data class ScreenShareResources(
|
||||
val track: VideoTrack,
|
||||
val source: VideoSource,
|
||||
)
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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.call
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.view.Surface
|
||||
import org.webrtc.CapturerObserver
|
||||
import org.webrtc.SurfaceTextureHelper
|
||||
import org.webrtc.ThreadUtils
|
||||
import org.webrtc.VideoCapturer
|
||||
import org.webrtc.VideoFrame
|
||||
import org.webrtc.VideoSink
|
||||
|
||||
/**
|
||||
* Captures the screen through [MediaProjection] and feeds it to WebRTC.
|
||||
*
|
||||
* Behaviourally a drop-in for `org.webrtc.ScreenCapturerAndroid` (a derivative of it — original
|
||||
* © 2016 The WebRTC project authors, BSD-style license), with one defect fixed: the upstream
|
||||
* class builds the capture `Surface` inline,
|
||||
*
|
||||
* ```java
|
||||
* virtualDisplay = mediaProjection.createVirtualDisplay(..., new Surface(helper.getSurfaceTexture()), ...);
|
||||
* ```
|
||||
*
|
||||
* and keeps no reference to it, so `Surface.release()` is never called. `VirtualDisplay.release()`
|
||||
* does not cover it — the Surface belongs to the caller — so every capture session leaked one,
|
||||
* reclaimed only whenever the finalizer next ran. It showed up as a StrictMode
|
||||
* `LeakedClosableViolation` pointing at `ScreenCapturerAndroid.createVirtualDisplay`, and
|
||||
* `changeCaptureFormat` leaked another one per call. This version owns the Surface and releases
|
||||
* it with the virtual display.
|
||||
*/
|
||||
class ScreenShareCapturer(
|
||||
private val mediaProjectionPermissionResultData: Intent,
|
||||
private val mediaProjectionCallback: MediaProjection.Callback,
|
||||
) : VideoCapturer,
|
||||
VideoSink {
|
||||
private var width: Int = 0
|
||||
private var height: Int = 0
|
||||
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
|
||||
/** The reference the upstream capturer drops on the floor. Released in [releaseDisplay]. */
|
||||
private var surface: Surface? = null
|
||||
|
||||
private var surfaceTextureHelper: SurfaceTextureHelper? = null
|
||||
private var capturerObserver: CapturerObserver? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var mediaProjectionManager: MediaProjectionManager? = null
|
||||
|
||||
private var numCapturedFrames: Long = 0
|
||||
private var isDisposed = false
|
||||
|
||||
private fun checkNotDisposed() {
|
||||
if (isDisposed) throw IllegalStateException("capturer is disposed.")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun initialize(
|
||||
surfaceTextureHelper: SurfaceTextureHelper,
|
||||
applicationContext: Context,
|
||||
capturerObserver: CapturerObserver,
|
||||
) {
|
||||
checkNotDisposed()
|
||||
this.surfaceTextureHelper = surfaceTextureHelper
|
||||
this.capturerObserver = capturerObserver
|
||||
this.mediaProjectionManager = applicationContext.getSystemService(MediaProjectionManager::class.java)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun startCapture(
|
||||
width: Int,
|
||||
height: Int,
|
||||
ignoredFramerate: Int,
|
||||
) {
|
||||
checkNotDisposed()
|
||||
this.width = width
|
||||
this.height = height
|
||||
|
||||
val helper = surfaceTextureHelper ?: throw IllegalStateException("surfaceTextureHelper not set.")
|
||||
val manager = mediaProjectionManager ?: throw IllegalStateException("capturer not initialized.")
|
||||
|
||||
val projection =
|
||||
manager.getMediaProjection(Activity.RESULT_OK, mediaProjectionPermissionResultData)
|
||||
?: throw IllegalStateException("MediaProjection permission data was rejected.")
|
||||
mediaProjection = projection
|
||||
|
||||
// Let the MediaProjection callback use the SurfaceTextureHelper thread.
|
||||
projection.registerCallback(mediaProjectionCallback, helper.handler)
|
||||
|
||||
createVirtualDisplay()
|
||||
capturerObserver?.onCapturerStarted(true)
|
||||
helper.startListening(this)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stopCapture() {
|
||||
checkNotDisposed()
|
||||
val helper = surfaceTextureHelper ?: return
|
||||
ThreadUtils.invokeAtFrontUninterruptibly(helper.handler) {
|
||||
helper.stopListening()
|
||||
capturerObserver?.onCapturerStopped()
|
||||
|
||||
releaseDisplay()
|
||||
|
||||
mediaProjection?.let { projection ->
|
||||
// Unregister the callback before stopping, otherwise the callback recursively
|
||||
// calls this method.
|
||||
projection.unregisterCallback(mediaProjectionCallback)
|
||||
projection.stop()
|
||||
}
|
||||
mediaProjection = null
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun dispose() {
|
||||
isDisposed = true
|
||||
// Best-effort. On the failure path startCapture() can have created the display and Surface
|
||||
// without a matching stopCapture(), and nothing else would hand them back.
|
||||
releaseDisplay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the output video size, e.g. when the captured screen rotates.
|
||||
*/
|
||||
@Synchronized
|
||||
override fun changeCaptureFormat(
|
||||
width: Int,
|
||||
height: Int,
|
||||
ignoredFramerate: Int,
|
||||
) {
|
||||
checkNotDisposed()
|
||||
this.width = width
|
||||
this.height = height
|
||||
|
||||
// Capturer is stopped; the virtual display will be created by startCapture().
|
||||
if (virtualDisplay == null) return
|
||||
|
||||
val helper = surfaceTextureHelper ?: return
|
||||
|
||||
// Recreate on the SurfaceTextureHelper thread to avoid interfering with frame processing,
|
||||
// which runs on that same thread.
|
||||
ThreadUtils.invokeAtFrontUninterruptibly(helper.handler) {
|
||||
releaseDisplay()
|
||||
createVirtualDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createVirtualDisplay() {
|
||||
val helper = surfaceTextureHelper ?: return
|
||||
val projection = mediaProjection ?: return
|
||||
|
||||
helper.setTextureSize(width, height)
|
||||
|
||||
val newSurface = Surface(helper.surfaceTexture)
|
||||
surface = newSurface
|
||||
virtualDisplay =
|
||||
projection.createVirtualDisplay(
|
||||
"WebRTC_ScreenCapture",
|
||||
width,
|
||||
height,
|
||||
VIRTUAL_DISPLAY_DPI,
|
||||
DISPLAY_FLAGS,
|
||||
newSurface,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
/** Releases the virtual display first, so nothing is still drawing into the Surface. */
|
||||
private fun releaseDisplay() {
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
surface?.release()
|
||||
surface = null
|
||||
}
|
||||
|
||||
/** Called on the internal looper thread of [SurfaceTextureHelper]. */
|
||||
override fun onFrame(frame: VideoFrame) {
|
||||
numCapturedFrames++
|
||||
capturerObserver?.onFrameCaptured(frame)
|
||||
}
|
||||
|
||||
override fun isScreencast(): Boolean = true
|
||||
|
||||
fun getNumCapturedFrames(): Long = numCapturedFrames
|
||||
|
||||
companion object {
|
||||
private const val DISPLAY_FLAGS =
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
|
||||
|
||||
/** DPI for the VirtualDisplay; does not appear to matter here. */
|
||||
private const val VIRTUAL_DISPLAY_DPI = 400
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -78,7 +78,7 @@ class MemoryTrimmingService(
|
||||
level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
|
||||
) {
|
||||
if (isTrimmingMemoryMutex.compareAndSet(false, true)) {
|
||||
Log.d("ServiceManager", "Trimming Memory (level=$level)")
|
||||
Log.d("ServiceManager") { "Trimming Memory (level=$level)" }
|
||||
try {
|
||||
doTrim(account, otherAccounts, level)
|
||||
} finally {
|
||||
|
||||
+22
-10
@@ -27,11 +27,12 @@ import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
|
||||
import okhttp3.Call
|
||||
@@ -57,8 +58,15 @@ class BlossomFetcher(
|
||||
class Factory(
|
||||
val blossomServerResolver: () -> BlossomServerResolver,
|
||||
val networkClient: (url: String) -> Call.Factory,
|
||||
// Shared with every other network-backed factory on this ImageLoader --
|
||||
// see the note in ImageLoaderSetup.setup(): the de-dupe only works when
|
||||
// all fetchers coordinate through the same instance.
|
||||
concurrentRequestStrategy: ConcurrentRequestStrategy,
|
||||
private val readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
|
||||
|
||||
override fun create(
|
||||
data: Uri,
|
||||
@@ -66,16 +74,20 @@ class BlossomFetcher(
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
if (!isApplicable(data)) return null
|
||||
// Wrapped per resolved url (not per Factory) because the server the
|
||||
// blob actually lives on is only known once the resolver has run.
|
||||
return BlossomFetcher(options, data, blossomServerResolver) { url ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
readAuthAware(url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.images
|
||||
|
||||
import coil3.Extras
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.network.HttpException
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/**
|
||||
* Retries an auth-gated Blossom blob with a signed BUD-01 `t=get` token when the
|
||||
* anonymous fetch comes back `401`.
|
||||
*
|
||||
* This is the half of the read-auth flow that has to wait for a signature.
|
||||
* `BlossomReadAuthInterceptor` cannot: it runs on an OkHttp dispatcher thread,
|
||||
* so waiting there holds one of the 16 per-host slots and stalls every other
|
||||
* image from the same host. `Fetcher.fetch()` is `suspend`, so the wait costs a
|
||||
* suspended coroutine and nothing else.
|
||||
*
|
||||
* [build] produces the underlying network fetcher, optionally carrying an
|
||||
* `Authorization` header. Deliberately a `(String?) -> Fetcher` lambda rather
|
||||
* than taking Coil's [Options] directly — the retry decision is then testable
|
||||
* without an Android `Context` to construct [Options] with.
|
||||
*/
|
||||
class BlossomReadAuthFetcher(
|
||||
private val url: String,
|
||||
private val auth: BlossomReadAuthTokenProvider,
|
||||
private val build: (authHeader: String?) -> Fetcher,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
try {
|
||||
return build(null).fetch()
|
||||
} catch (e: HttpException) {
|
||||
// Coil's NetworkFetcher throws HttpException for any non-2xx/304,
|
||||
// which is how a 401 reaches us with its code intact.
|
||||
if (e.response.code != HTTP_UNAUTHORIZED) throw e
|
||||
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: throw e
|
||||
// Gate only: read-auth applies to Blossom blob URLs, but the token is
|
||||
// scoped to the host (BUD-11 `server` tag) and carries no `x` tag.
|
||||
BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
|
||||
val header = auth.header(httpUrl.host) ?: throw e
|
||||
|
||||
return build(header).fetch()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps [build] in read-auth handling when a token provider is configured, and
|
||||
* returns the plain fetcher when it isn't (tests, pre-configuration call sites).
|
||||
*/
|
||||
fun readAuthAware(
|
||||
url: String,
|
||||
auth: BlossomReadAuthTokenProvider?,
|
||||
build: (authHeader: String?) -> Fetcher,
|
||||
): Fetcher =
|
||||
if (auth == null) {
|
||||
build(null)
|
||||
} else {
|
||||
BlossomReadAuthFetcher(url, auth, build)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy of these options carrying [header] as `Authorization`, or the same
|
||||
* options when there is no header. Coil's `NetworkFetcher` builds its request
|
||||
* from `options.httpHeaders`, so this is how the retry gets authenticated.
|
||||
*/
|
||||
fun Options.withAuthHeader(header: String?): Options =
|
||||
if (header == null) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
extras =
|
||||
extras
|
||||
.newBuilder()
|
||||
.set(
|
||||
Extras.Key.httpHeaders,
|
||||
httpHeaders.newBuilder().set("Authorization", header).build(),
|
||||
).build(),
|
||||
)
|
||||
}
|
||||
+32
-13
@@ -33,6 +33,7 @@ import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
@@ -43,6 +44,7 @@ import coil3.svg.SvgDecoder
|
||||
import coil3.util.Logger
|
||||
import coil3.video.VideoFrameDecoder
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -60,7 +62,7 @@ class ImageLoaderSetup {
|
||||
|
||||
val debugLogger = if (isDebug) MyDebugLogger() else null
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
@OptIn(DelicateCoilApi::class, ExperimentalCoilApi::class)
|
||||
fun setup(
|
||||
app: Context,
|
||||
diskCache: () -> DiskCache,
|
||||
@@ -69,7 +71,19 @@ class ImageLoaderSetup {
|
||||
callFactory: (url: String) -> Call.Factory,
|
||||
thumbnailCache: ThumbnailDiskCache,
|
||||
backgroundScope: CoroutineScope,
|
||||
// Signs the BUD-01 retry when a gated host answers 401. Null keeps every
|
||||
// fetch anonymous (tests, pre-configuration call sites).
|
||||
readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) {
|
||||
// ONE strategy for the whole ImageLoader. DeDupeConcurrentRequestStrategy
|
||||
// coordinates through a map of in-flight fetches that it owns, so it only
|
||||
// works when every fetcher shares the same instance -- a fresh one per
|
||||
// request can never see anybody else's fetch and the de-dupe silently
|
||||
// no-ops. Shared across all three network-backed factories so a feed
|
||||
// image and the same blob reached through `blossom:` (or a profile
|
||||
// picture) still collapse onto one download.
|
||||
val concurrentRequests = DeDupeConcurrentRequestStrategy()
|
||||
|
||||
SingletonImageLoader.setUnsafe(
|
||||
ImageLoader
|
||||
.Builder(app)
|
||||
@@ -87,13 +101,13 @@ class ImageLoaderSetup {
|
||||
add(Base64Fetcher.Factory)
|
||||
add(BlurHashFetcher.Factory)
|
||||
add(ThumbHashFetcher.Factory)
|
||||
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
|
||||
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope))
|
||||
add(BlossomFetcher.Factory(blossomServerResolver, callFactory, concurrentRequests, readAuth))
|
||||
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope, concurrentRequests, readAuth))
|
||||
add(Base64Fetcher.BKeyer)
|
||||
add(BlurHashFetcher.BKeyer)
|
||||
add(ThumbHashFetcher.TKeyer)
|
||||
add(ProfilePictureFetcher.BKeyer)
|
||||
add(OkHttpFactory(callFactory))
|
||||
add(OkHttpFactory(callFactory, concurrentRequests, readAuth))
|
||||
}.build(),
|
||||
)
|
||||
}
|
||||
@@ -132,9 +146,12 @@ class MyDebugLogger(
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
class OkHttpFactory(
|
||||
val networkClient: (url: String) -> Call.Factory,
|
||||
concurrentRequestStrategy: ConcurrentRequestStrategy,
|
||||
private val readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
|
||||
|
||||
override fun create(
|
||||
data: Uri,
|
||||
@@ -145,15 +162,17 @@ class OkHttpFactory(
|
||||
|
||||
val url = data.toString()
|
||||
|
||||
return NetworkFetcher(
|
||||
url = url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
return readAuthAware(url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isApplicable(data: Uri): Boolean = data.scheme == "http" || data.scheme == "https"
|
||||
|
||||
+21
-10
@@ -30,12 +30,13 @@ import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
@@ -95,8 +96,16 @@ class ProfilePictureFetcher(
|
||||
private val thumbnailCache: ThumbnailDiskCache,
|
||||
private val networkClient: (url: String) -> Call.Factory,
|
||||
private val backgroundScope: CoroutineScope,
|
||||
// Shared with every other network-backed factory on this ImageLoader --
|
||||
// see the note in ImageLoaderSetup.setup(). Avatars repeat constantly
|
||||
// down a feed, so this is where a per-request instance cost the most:
|
||||
// every row holding the same author's picture downloaded it again.
|
||||
concurrentRequestStrategy: ConcurrentRequestStrategy,
|
||||
private val readAuth: BlossomReadAuthTokenProvider? = null,
|
||||
) : Fetcher.Factory<ProfilePictureUrl> {
|
||||
private val cacheStrategyLazy = lazy { CacheStrategy.DEFAULT }
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
private val concurrentRequestStrategyLazy = lazyOf(concurrentRequestStrategy)
|
||||
|
||||
override fun create(
|
||||
data: ProfilePictureUrl,
|
||||
@@ -106,15 +115,17 @@ class ProfilePictureFetcher(
|
||||
val diskCacheLazy = lazy { imageLoader.diskCache }
|
||||
|
||||
val netFetcher =
|
||||
NetworkFetcher(
|
||||
url = data.url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(data.url).asNetworkClient() },
|
||||
diskCache = diskCacheLazy,
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
readAuthAware(data.url, readAuth) { authHeader ->
|
||||
NetworkFetcher(
|
||||
url = data.url,
|
||||
options = options.withAuthHeader(authHeader),
|
||||
networkClient = lazy { networkClient(data.url).asNetworkClient() },
|
||||
diskCache = diskCacheLazy,
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = concurrentRequestStrategyLazy,
|
||||
)
|
||||
}
|
||||
|
||||
return ProfilePictureFetcher(
|
||||
data.url,
|
||||
|
||||
+16
-1
@@ -201,6 +201,13 @@ class LightningAddressResolver {
|
||||
?: response.code.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onZapRequestSent receives the zap request that was ACTUALLY sent to the
|
||||
* callback, or null when it was not. A provider that does not advertise
|
||||
* `allowsNostr` never sees [nostrRequest], and its invoice therefore commits to
|
||||
* nothing about it — so a caller must not go on to claim the two are bound. See
|
||||
* the drop below.
|
||||
*/
|
||||
suspend fun lnAddressInvoice(
|
||||
lnAddress: String,
|
||||
milliSats: Long,
|
||||
@@ -209,6 +216,7 @@ class LightningAddressResolver {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onZapRequestSent: (LnZapRequestEvent?) -> Unit = {},
|
||||
): String {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
@@ -264,12 +272,19 @@ class LightningAddressResolver {
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-57 binds a zap request to its invoice through `description_hash`, and a
|
||||
// provider that ignores `nostr=` mints an invoice that commits to nothing about
|
||||
// it. Report what actually went, so a caller cannot attach the event to a
|
||||
// payment it was never bound to.
|
||||
val sentZapRequest = nostrRequest?.takeIf { allowsNostr }
|
||||
onZapRequestSent(sentZapRequest)
|
||||
|
||||
val invoice =
|
||||
fetchLightningInvoice(
|
||||
lnCallback = callbackUrl,
|
||||
milliSats = milliSats,
|
||||
message = message,
|
||||
nostrRequest = if (allowsNostr) nostrRequest else null,
|
||||
nostrRequest = sentZapRequest,
|
||||
okHttpClient = okHttpClient,
|
||||
context = context,
|
||||
)
|
||||
|
||||
+10
@@ -76,6 +76,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -278,6 +283,11 @@ class EventNotificationConsumer(
|
||||
is GitPatchEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitPullRequestEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitPullRequestUpdateEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitReplyEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusOpenEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusAppliedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
|
||||
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
|
||||
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
|
||||
|
||||
+16
-1
@@ -47,6 +47,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
@@ -105,7 +110,9 @@ class NotificationDispatcher(
|
||||
// consumeFromCache can't route it. It's delivered directly via
|
||||
// [notifyWelcome] from processMarmotWelcomeFlow, which does know the
|
||||
// recipient account.
|
||||
private val NOTIFICATION_KINDS: Set<Int> =
|
||||
// `internal` (was `private`) so the notification-kinds contract test
|
||||
// can pin the push-side kind set against the in-app feed's kind set.
|
||||
internal val NOTIFICATION_KINDS: Set<Int> =
|
||||
setOf(
|
||||
// Direct-arrival
|
||||
PrivateDmEvent.KIND,
|
||||
@@ -131,6 +138,14 @@ class NotificationDispatcher(
|
||||
GitIssueEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 threaded activity: legacy git-reply comment (1622)
|
||||
// and the four status transitions (open/applied/closed/draft,
|
||||
// kinds 1630-1633). Same push channel as issues/patches/PRs.
|
||||
GitReplyEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
|
||||
+54
-7
@@ -92,6 +92,11 @@ class NotificationRelayService : Service() {
|
||||
// Keeps notification updates well under Android's rate limit (~10/s).
|
||||
private const val NOTIFICATION_REFRESH_MS = 1000L
|
||||
|
||||
// Toggles the expanded per-job breakdown on and off. Fired by the notification's own
|
||||
// action button, so the details are always something the user asked for.
|
||||
private const val ACTION_SHOW_DETAILS = "com.vitorpamplona.amethyst.SHOW_NOTIFICATION_SERVICE_DETAILS"
|
||||
private const val ACTION_HIDE_DETAILS = "com.vitorpamplona.amethyst.HIDE_NOTIFICATION_SERVICE_DETAILS"
|
||||
|
||||
const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE"
|
||||
|
||||
fun start(context: Context) {
|
||||
@@ -149,6 +154,9 @@ class NotificationRelayService : Service() {
|
||||
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
|
||||
private var lastBreakdown: List<String> = emptyList()
|
||||
|
||||
/** Whether the user asked for the per-job breakdown. Off until they tap "show details". */
|
||||
private var detailsExpanded = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -165,6 +173,13 @@ class NotificationRelayService : Service() {
|
||||
startId: Int,
|
||||
): Int {
|
||||
Log.d(TAG, "Starting service")
|
||||
// The details toggle re-enters here through the notification's action button. It only
|
||||
// flips the flag; the rebuild happens in the ensureForeground() below, which reposts the
|
||||
// notification with (or without) the breakdown.
|
||||
when (intent?.action) {
|
||||
ACTION_SHOW_DETAILS -> detailsExpanded = true
|
||||
ACTION_HIDE_DETAILS -> detailsExpanded = false
|
||||
}
|
||||
// Every startForegroundService() call re-arms Android's "must call
|
||||
// startForeground() within the timeout" requirement — including the repeated
|
||||
// calls MainActivity.onResume fires on each resume, even when the service is
|
||||
@@ -359,9 +374,12 @@ class NotificationRelayService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// Expanded only. The collapsed line stays the bare count it has always been — that is all
|
||||
// most people want from an ongoing notification — and the per-job breakdown appears solely
|
||||
// when someone deliberately expands it to ask why the phone is talking to N relays.
|
||||
// Opt-in, never automatic. Android auto-expands a notification when it is the only one in
|
||||
// the shade, and there is no way to opt out of that — so attaching the breakdown as a
|
||||
// BigTextStyle up front made the per-job list the *default* view for anyone whose shade was
|
||||
// otherwise empty, which is the opposite of what it is for. The card is therefore built
|
||||
// with no expanded style at all until someone taps "show details"; that reposts it with the
|
||||
// breakdown and a "hide details" action that puts it back to the bare count.
|
||||
//
|
||||
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
|
||||
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
|
||||
@@ -369,9 +387,37 @@ class NotificationRelayService : Service() {
|
||||
// looking at it. What each connection is *for* does not change while it is re-establishing,
|
||||
// so the last known answer is still the right one; only the count above it goes stale, and
|
||||
// that count is already labelled "connecting".
|
||||
val fresh = RelayPurposeSummary.lines(this)
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
|
||||
//
|
||||
// Computed only while expanded: walking every connected relay's active requests once a
|
||||
// second is wasted work when nobody has asked to see the result.
|
||||
val breakdown =
|
||||
if (detailsExpanded) {
|
||||
val fresh = RelayPurposeSummary.lines(this)
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
lastBreakdown.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val detailsIntent =
|
||||
Intent(this, NotificationRelayService::class.java).apply {
|
||||
action = if (detailsExpanded) ACTION_HIDE_DETAILS else ACTION_SHOW_DETAILS
|
||||
}
|
||||
val detailsPendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
if (detailsExpanded) 4 else 3,
|
||||
detailsIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val detailsLabel =
|
||||
getString(
|
||||
if (detailsExpanded) {
|
||||
R.string.always_on_notif_hide_details
|
||||
} else {
|
||||
R.string.always_on_notif_show_details
|
||||
},
|
||||
)
|
||||
|
||||
// Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it
|
||||
// sits in the shade's Silent section next to the low-importance content kinds
|
||||
@@ -396,7 +442,8 @@ class NotificationRelayService : Service() {
|
||||
.bigText(contentText + "\n\n" + it.joinToString("\n")),
|
||||
)
|
||||
}
|
||||
}.setSmallIcon(R.drawable.amethyst_service)
|
||||
}.addAction(0, detailsLabel, detailsPendingIntent)
|
||||
.setSmallIcon(R.drawable.amethyst_service)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
|
||||
+5
-3
@@ -32,9 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
/**
|
||||
* The per-job breakdown behind the always-on notification's relay count.
|
||||
*
|
||||
* **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because
|
||||
* that is all most people ever want from an ongoing notification. This is for the moment someone
|
||||
* taps to ask *why* their phone is talking to 40 relays.
|
||||
* **Only ever shown on request.** The card stays exactly what it was — a count — because that is all
|
||||
* most people ever want from an ongoing notification. This is for the moment someone taps
|
||||
* "show details" to ask *why* their phone is talking to 40 relays. It is not attached to the
|
||||
* notification otherwise: Android auto-expands a lone notification, so anything hung off the
|
||||
* expanded view alone would be the default view rather than an opt-in one.
|
||||
*
|
||||
* A relay usually serves several jobs at once (measured: a typical relay carries four), so these
|
||||
* counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays
|
||||
|
||||
+99
-2
@@ -35,12 +35,26 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
/**
|
||||
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
|
||||
* (1618) and PR updates (1619) on repos you maintain. Rendered as a slate card
|
||||
* titled by the action ("X opened an issue" …) with the subject as the body.
|
||||
* (1618), PR updates (1619), replies (1622, legacy), and status transitions
|
||||
* (1630 open, 1631 applied/merged, 1632 closed, 1633 draft) on repos or threads
|
||||
* you're p-tagged into. Rendered as a slate card titled by the action ("X opened
|
||||
* an issue", "X merged a pull request", …) with the subject as the body.
|
||||
* Author name + avatar enriched observably.
|
||||
*
|
||||
* Status kinds resolve their title from the *target* event's kind (patch/PR/issue)
|
||||
* when it's in cache, so a merge on a PR reads "merged a pull request" but the
|
||||
* same 1631 targeting a plain kind-1617 patch reads "applied a patch". Falls
|
||||
* back to a generic wording when the target isn't yet resolved (rare: the
|
||||
* notification lands after the target because the p-tag subscription pulls
|
||||
* status events regardless of whether the target has been seen).
|
||||
*/
|
||||
object CodeNotification {
|
||||
suspend fun notify(
|
||||
@@ -67,6 +81,89 @@ object CodeNotification {
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitReplyEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusOpenEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusAppliedEvent,
|
||||
) = post(
|
||||
context,
|
||||
account,
|
||||
event.id,
|
||||
event.createdAt,
|
||||
event.pubKey,
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_applied_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_applied_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_applied_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_applied,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusClosedEvent,
|
||||
) = post(
|
||||
context,
|
||||
account,
|
||||
event.id,
|
||||
event.createdAt,
|
||||
event.pubKey,
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_closed_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_closed_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_closed_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_closed,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusDraftEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
|
||||
|
||||
/**
|
||||
* Pick a title string for a status event based on the *target*'s kind, so
|
||||
* a 1631 on a kind-1618 PR reads "merged a pull request" while the same
|
||||
* status kind on a kind-1617 patch reads "applied a patch". [rootId] is
|
||||
* the marked-`root` `e` tag on the status event; when the target isn't in
|
||||
* cache we return [fallback] which is deliberately generic.
|
||||
*/
|
||||
private fun titleForStatusOnTarget(
|
||||
rootId: String?,
|
||||
pr: Int,
|
||||
patch: Int,
|
||||
issue: Int,
|
||||
fallback: Int,
|
||||
): Int {
|
||||
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
|
||||
return when (targetKind) {
|
||||
GitPullRequestEvent.KIND -> pr
|
||||
GitPatchEvent.KIND -> patch
|
||||
GitIssueEvent.KIND -> issue
|
||||
else -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun post(
|
||||
context: Context,
|
||||
account: Account,
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ object NwcPaymentNotifier {
|
||||
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
|
||||
|
||||
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
|
||||
val comment = (tx.parsedMetadata()?.comment ?: tx.description)?.ifBlank { null }
|
||||
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
|
||||
val body = comment ?: title
|
||||
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
|
||||
+27
-20
@@ -49,22 +49,27 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* - and at most one retry (an application interceptor's second `chain.proceed`
|
||||
* runs the downstream chain again, it does not re-enter this interceptor).
|
||||
*
|
||||
* [authHeaderProvider] is `(host, sha256) -> header?`. It is synchronous by
|
||||
* contract (the caller bridges the suspend signer), returns `null` when no
|
||||
* signer is available or signing times out, and is only consulted on a real
|
||||
* `401`, so an unauthenticated user simply keeps seeing the broken image
|
||||
* rather than paying any signing cost.
|
||||
* This interceptor never signs and never waits. [cachedHeaderProvider] is a
|
||||
* pure cache read and [onAuthRequired] is fire-and-forget: `intercept` runs on
|
||||
* an OkHttp dispatcher thread, where blocking would hold one of the 16 per-host
|
||||
* slots for the whole signing window and stall every other image from that
|
||||
* host. The signed *retry* therefore lives one layer up, in
|
||||
* `BlossomReadAuthFetcher`, which is `suspend` and can await the signature
|
||||
* without occupying a slot.
|
||||
*
|
||||
* The first blob from an auth-gated host costs an extra round trip (anonymous
|
||||
* `GET` → `401` → signed retry), but that host is then remembered in
|
||||
* [knownAuthHosts] so every later blob from it is signed **up front** — one
|
||||
* round trip, not two. This matters on a Buzz community feed where nearly every
|
||||
* image comes from the same gated host: without it each image would keep paying
|
||||
* the wasted 401 probe. The learned host also short-circuits to anonymous when
|
||||
* no signer is available, so a logged-out user never re-probes needlessly.
|
||||
* The first blob from an auth-gated host still costs an extra round trip
|
||||
* (anonymous `GET` -> `401` -> signed retry by the fetcher), but the host is
|
||||
* then remembered in [knownAuthHosts] so every later blob from it is signed
|
||||
* **up front** from the cache — one round trip, not two. This matters on a Buzz
|
||||
* community feed where nearly every image comes from the same gated host.
|
||||
* Callers that cannot retry (e.g. the media3 video datasource) get the token on
|
||||
* their next request, once [onAuthRequired] has landed it in the cache.
|
||||
*/
|
||||
class BlossomReadAuthInterceptor(
|
||||
private val authHeaderProvider: (host: String, sha256: HexKey) -> String?,
|
||||
/** Pure cache read — must not sign, must not block. */
|
||||
private val cachedHeaderProvider: (host: String) -> String?,
|
||||
/** Fire-and-forget: starts a signature for a host we just learned is gated. */
|
||||
private val onAuthRequired: (host: String) -> Unit,
|
||||
) : Interceptor {
|
||||
// Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user
|
||||
// follows a handful of auth-gated servers at most) and shared across all
|
||||
@@ -81,14 +86,16 @@ class BlossomReadAuthInterceptor(
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
// The hash is a gate, not an input: read-auth applies only to Blossom
|
||||
// blob URLs. The token itself is host-scoped and carries no `x` tag.
|
||||
blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
val host = request.url.host
|
||||
|
||||
// Known-gated host: skip the anonymous probe and sign the first attempt.
|
||||
// Falls through to anonymous only when we can't produce a token (no
|
||||
// signer / timeout) — the server would 401 either way.
|
||||
if (host in knownAuthHosts) {
|
||||
authHeaderProvider(host, sha256)?.let { header ->
|
||||
cachedHeaderProvider(host)?.let { header ->
|
||||
return chain.proceed(request.withAuth(header))
|
||||
}
|
||||
}
|
||||
@@ -99,12 +106,12 @@ class BlossomReadAuthInterceptor(
|
||||
// Learn the host so its next blob is signed up front.
|
||||
knownAuthHosts.add(host)
|
||||
|
||||
val header = authHeaderProvider(host, sha256) ?: return response
|
||||
// Start the signature but do not wait for it: this thread holds a
|
||||
// per-host dispatcher slot. BlossomReadAuthFetcher performs the signed
|
||||
// retry for this very request from a coroutine.
|
||||
onAuthRequired(host)
|
||||
|
||||
// Close the 401 body before replaying so the connection can be reused.
|
||||
response.close()
|
||||
|
||||
return chain.proceed(request.withAuth(header))
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.withAuth(header: String) =
|
||||
|
||||
+114
-33
@@ -21,29 +21,49 @@
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Signs and caches BUD-01 read-auth headers for [BlossomReadAuthInterceptor].
|
||||
* Signs and caches BUD-01 read-auth headers for auth-gated Blossom hosts.
|
||||
*
|
||||
* The interceptor is synchronous (it runs on an OkHttp dispatcher thread) but
|
||||
* signing is `suspend`, so [authHeader] bridges with [runBlocking] guarded by a
|
||||
* timeout: an internal key signs instantly, while a remote (NIP-46) or external
|
||||
* (NIP-55) signer that hangs or needs user interaction simply yields `null` and
|
||||
* the download stays unauthenticated instead of pinning the thread.
|
||||
* Signing never blocks a caller's thread. It runs on [scope]; callers either
|
||||
* `suspend` on [header] or fire [warm] and pick the token up later. That
|
||||
* matters because the consumer used to be [BlossomReadAuthInterceptor], which
|
||||
* runs on an OkHttp dispatcher thread — bridging the suspend signer with
|
||||
* `runBlocking` there held one of the 16 per-host dispatcher slots for as long
|
||||
* as the signer took (up to the timeout), so a feed's first burst against a
|
||||
* gated host could occupy every slot and stall every other image from it.
|
||||
*
|
||||
* Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token
|
||||
* grants reads for every blob on the host (thumbnails included), so one signed
|
||||
* event covers a whole feed's worth of images from an auth-gated host for the
|
||||
* life of the token. The blob hash of the request that first triggered signing
|
||||
* is still included as the `x` tag for BUD-01 servers that check it.
|
||||
* One signature per host, however many callers. The token cache alone couldn't
|
||||
* provide that: it is only populated *after* a signature returns, so a cold
|
||||
* burst of N images all missed it and all signed concurrently — with a NIP-55
|
||||
* external signer that meant N IPC round trips (and potentially N prompts).
|
||||
* [inFlight] is what collapses them; the leader signs and every follower awaits
|
||||
* the same [CompletableDeferred].
|
||||
*
|
||||
* Tokens are cached per host, not per blob, and are therefore minted with a
|
||||
* BUD-11 `server` tag and **no** `x` tag. BUD-11 lists `x` as optional for
|
||||
* `GET /<sha256>` but is strict about what including one means: "When `x` tags
|
||||
* are present, the token is only valid for operations on the specified blob
|
||||
* hashes." A token carrying the hash of whichever blob happened to trigger
|
||||
* signing would therefore be invalid for every other blob it was reused for.
|
||||
* Server-scoped and hash-free, one signed event legitimately covers a whole
|
||||
* feed's worth of images from the host for the life of the token.
|
||||
*
|
||||
* The tradeoff that buys: the token authorizes reading any blob on that host
|
||||
* until it expires, rather than one. It is only ever sent to that host, over
|
||||
* TLS, and BUD-11 sanctions the shape — but it is a wider grant than a
|
||||
* per-blob token, which is the price of caching at all.
|
||||
*/
|
||||
class BlossomReadAuthTokenProvider(
|
||||
private val signerProvider: () -> NostrSigner?,
|
||||
private val scope: CoroutineScope,
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private class CachedToken(
|
||||
@@ -52,31 +72,91 @@ class BlossomReadAuthTokenProvider(
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<String, CachedToken>()
|
||||
private val inFlight = ConcurrentHashMap<String, CompletableDeferred<String?>>()
|
||||
|
||||
fun authHeader(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
): String? {
|
||||
val now = clock()
|
||||
/**
|
||||
* The token already held for [host], or null. Pure map read — safe to call
|
||||
* from an OkHttp interceptor, and never signs.
|
||||
*/
|
||||
fun cachedHeader(host: String): String? = cache[host]?.takeIf { it.expiresAtMs > clock() }?.header
|
||||
|
||||
cache[host]?.let { if (it.expiresAtMs > now) return it.header }
|
||||
/**
|
||||
* The token for [host], signing one if none is cached. Suspends rather than
|
||||
* blocking, so the caller must already be in a coroutine — on the image path
|
||||
* that is Coil's `Fetcher.fetch()`.
|
||||
*/
|
||||
suspend fun header(host: String): String? {
|
||||
cachedHeader(host)?.let { return it }
|
||||
return signOnce(host)?.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a signature for [host] without waiting for it. For callers that
|
||||
* cannot suspend (the interceptor) and only need the token to exist by the
|
||||
* time some later request needs it.
|
||||
*/
|
||||
fun warm(host: String) {
|
||||
if (cachedHeader(host) != null) return
|
||||
signOnce(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the in-flight signature for [host], starting one if this caller
|
||||
* wins the race. Null when there is no signer to sign with.
|
||||
*
|
||||
* Leader/follower over [ConcurrentHashMap.putIfAbsent] rather than
|
||||
* `computeIfAbsent`: the completion handler removes the map entry, and a job
|
||||
* that finishes immediately would run that removal *inside* the mapping
|
||||
* function, which `ConcurrentHashMap` forbids.
|
||||
*/
|
||||
private fun signOnce(host: String): CompletableDeferred<String?>? {
|
||||
inFlight[host]?.let { return it }
|
||||
|
||||
val signer = signerProvider() ?: return null
|
||||
|
||||
val header =
|
||||
runBlocking {
|
||||
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
|
||||
BlossomAuth.createGetAuth(
|
||||
hash = sha256,
|
||||
alt = "Downloading media from $host",
|
||||
signer = signer,
|
||||
servers = listOf(host),
|
||||
)
|
||||
}
|
||||
} ?: return null
|
||||
val fresh = CompletableDeferred<String?>()
|
||||
inFlight.putIfAbsent(host, fresh)?.let { return it }
|
||||
|
||||
cache[host] = CachedToken(header, now + CACHE_TTL_MS)
|
||||
return header
|
||||
scope
|
||||
.launch {
|
||||
val header =
|
||||
try {
|
||||
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
|
||||
BlossomAuth.createGetAuth(
|
||||
// No `x` tag: this token is reused for every blob
|
||||
// on the host. See the class kdoc.
|
||||
hash = null,
|
||||
alt = "Downloading media from $host",
|
||||
signer = signer,
|
||||
servers = listOf(host),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
if (header != null) {
|
||||
cache[host] = CachedToken(header, clock() + CACHE_TTL_MS)
|
||||
}
|
||||
|
||||
// Retire the entry *before* completing it. `invokeOnCompletion` fires when the
|
||||
// job ends, which is after `complete()` resumes the awaiting caller — so a
|
||||
// caller that returned from `header()` could come straight back, find this
|
||||
// finished deferred still in the map, and be handed its already-signed token
|
||||
// instead of signing a new one. A caller whose token has just expired does
|
||||
// exactly that, and got the expired token back for as long as the window
|
||||
// lasted — [refreshesAfterExpiry] closes it immediately and so hit it every run.
|
||||
inFlight.remove(host, fresh)
|
||||
fresh.complete(header)
|
||||
}.invokeOnCompletion {
|
||||
inFlight.remove(host, fresh)
|
||||
// No-op when the job completed normally; releases followers when
|
||||
// it was cancelled (scope torn down) instead of hanging them.
|
||||
fresh.complete(null)
|
||||
}
|
||||
|
||||
return fresh
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -84,7 +164,8 @@ class BlossomReadAuthTokenProvider(
|
||||
// refresh a little early to avoid handing over a token that dies mid-flight.
|
||||
private const val CACHE_TTL_MS = 55L * 60L * 1000L
|
||||
|
||||
// Bounds how long an image download may block waiting on a slow signer.
|
||||
// Bounds how long an image may wait on a slow signer. No thread is held
|
||||
// for this window any more — only the waiting coroutine.
|
||||
private const val SIGN_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
}
|
||||
|
||||
+12
-18
@@ -20,13 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.RelayClassification
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -101,9 +101,7 @@ class RelayProxyClientConnector(
|
||||
// flipped relay would sit out its (now-irrelevant) backoff. We track these so such a relay can
|
||||
// skip its retry delay on the next reconnect — scoped to onlyIfChanged, so only the relays that
|
||||
// actually flipped re-dial and the rest of the pool's backoff is left untouched.
|
||||
private var lastTrustedRelays: Set<NormalizedRelayUrl>? = null
|
||||
private var lastDmRelays: Set<NormalizedRelayUrl>? = null
|
||||
private var lastMoneyOpRelays: Set<NormalizedRelayUrl>? = null
|
||||
private var lastClassification: RelayClassification? = null
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val relayServices =
|
||||
@@ -152,7 +150,7 @@ class RelayProxyClientConnector(
|
||||
onTrigger(UsageKeys.TRIGGER_OFF)
|
||||
client.disconnect()
|
||||
}
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
if (infra.torStatus.isFullyBootstrapped) {
|
||||
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
|
||||
}
|
||||
// disconnect() already cleared every relay's backoff. Forget the network
|
||||
@@ -163,7 +161,7 @@ class RelayProxyClientConnector(
|
||||
infra.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
|
||||
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
||||
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
if (infra.torStatus.isFullyBootstrapped) {
|
||||
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
|
||||
}
|
||||
|
||||
@@ -174,9 +172,7 @@ class RelayProxyClientConnector(
|
||||
lastTorSettings = torSettings
|
||||
lastTorConnection = infra.torConnection
|
||||
lastClearConnection = infra.clearConnection
|
||||
lastTrustedRelays = infra.evaluator.trustedRelayList
|
||||
lastDmRelays = infra.evaluator.dmRelayList
|
||||
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
|
||||
lastClassification = infra.evaluator.classification
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -202,13 +198,13 @@ class RelayProxyClientConnector(
|
||||
// so let onlyIfChanged pick out the flipped relay(s) and skip THEIR retry delay —
|
||||
// without resetBackoff(), so the rest of the pool's backoff is untouched (these sets
|
||||
// churn while relay lists load, and forgiving the whole pool then would be too much).
|
||||
//
|
||||
// One comparison over the whole classification, not one per category: this used to
|
||||
// be a four-way `||` and adding a category meant remembering to extend it. Missing
|
||||
// a term fails silently — the affected relays keep a socket on a transport the
|
||||
// policy has already moved them off.
|
||||
val classificationChanged =
|
||||
lastTrustedRelays != null &&
|
||||
(
|
||||
infra.evaluator.trustedRelayList != lastTrustedRelays ||
|
||||
infra.evaluator.dmRelayList != lastDmRelays ||
|
||||
infra.evaluator.moneyOpRelayList != lastMoneyOpRelays
|
||||
)
|
||||
lastClassification != null && infra.evaluator.classification != lastClassification
|
||||
|
||||
val previousNetworkId = lastNetworkId
|
||||
|
||||
@@ -216,9 +212,7 @@ class RelayProxyClientConnector(
|
||||
lastClearConnection = infra.clearConnection
|
||||
lastNetworkId = networkId ?: lastNetworkId
|
||||
lastTorSettings = torSettings
|
||||
lastTrustedRelays = infra.evaluator.trustedRelayList
|
||||
lastDmRelays = infra.evaluator.dmRelayList
|
||||
lastMoneyOpRelays = infra.evaluator.moneyOpRelayList
|
||||
lastClassification = infra.evaluator.classification
|
||||
|
||||
if (networkChanged) {
|
||||
Log.d("ManageRelayServices") {
|
||||
|
||||
+2
-2
@@ -265,8 +265,8 @@ private fun RelayHeader(
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = info?.id ?: prompt.relayUrl.displayUrl(),
|
||||
model = info?.icon,
|
||||
robot = info.id ?: prompt.relayUrl.displayUrl(),
|
||||
model = info.icon,
|
||||
contentDescription = null,
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier = Modifier.size(34.dp).clip(MaterialTheme.shapes.small),
|
||||
|
||||
+2
-2
@@ -140,13 +140,13 @@ object RelayAuthPurposeDeriver {
|
||||
// `e` tags — take whichever we get so the prompt can say whose conversation.
|
||||
AuthPurposeKind.THREAD -> {
|
||||
readsThread = true
|
||||
explained?.entityIds?.let(readThreadNotes::addAll)
|
||||
explained.entityIds?.let(readThreadNotes::addAll)
|
||||
filter.tags?.get("e")?.let(readThreadNotes::addAll)
|
||||
}
|
||||
// Declared, but the *who*/*what* still comes from the filter. Prefer the entity
|
||||
// ids the assembler named over sniffing tags, and fall back when it named none.
|
||||
AuthPurposeKind.READ_VENUE -> {
|
||||
val declared = explained?.entityIds.orEmpty()
|
||||
val declared = explained.entityIds.orEmpty()
|
||||
if (declared.isNotEmpty()) readVenues.addAll(declared) else readVenues.addAll(filter.venueTags())
|
||||
}
|
||||
AuthPurposeKind.READ_OUTBOX -> {
|
||||
|
||||
+16
-16
@@ -222,17 +222,16 @@ class BootRelayDiagnostics(
|
||||
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
|
||||
}
|
||||
|
||||
Log.d(TAG, "===== boot census @${atSeconds}s =====")
|
||||
Log.i(
|
||||
TAG,
|
||||
Log.d(TAG) { "===== boot census @${atSeconds}s =====" }
|
||||
Log.i(TAG) {
|
||||
"census @${atSeconds}s pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
|
||||
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
|
||||
"events=${snapshot.values.sumOf { it.events.get() }} " +
|
||||
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
|
||||
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
|
||||
)
|
||||
Log.i(TAG, "census @${atSeconds}s failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
Log.i(TAG, "census @${atSeconds}s closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
|
||||
"auths=${snapshot.values.sumOf { it.authsSent.get() }}"
|
||||
}
|
||||
Log.i(TAG) { "census @${atSeconds}s failures_by_cause=${causeTotals.byCountDesc()}" }
|
||||
Log.i(TAG) { "census @${atSeconds}s closed_by_prefix=${closedTotals.byCountDesc()}" }
|
||||
|
||||
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
|
||||
Log.d(TAG, "--- top wasted dials (no events received) ---")
|
||||
@@ -242,13 +241,12 @@ class BootRelayDiagnostics(
|
||||
.sortedByDescending { it.value.tentatives.get() }
|
||||
.take(25)
|
||||
.forEach { (url, r) ->
|
||||
Log.d(
|
||||
TAG,
|
||||
Log.d(TAG) {
|
||||
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
|
||||
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
|
||||
"closed=[${r.closed.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
|
||||
"reqs=${r.reqsSent.get()} eose=${r.eoses.get()}",
|
||||
)
|
||||
"reqs=${r.reqsSent.get()} eose=${r.eoses.get()}"
|
||||
}
|
||||
}
|
||||
|
||||
// The relays actually carrying the boot, so a suppression change can be checked for
|
||||
@@ -258,12 +256,14 @@ class BootRelayDiagnostics(
|
||||
.sortedByDescending { it.value.events.get() }
|
||||
.take(20)
|
||||
.forEach { (url, r) ->
|
||||
Log.d(
|
||||
TAG,
|
||||
Log.d(TAG) {
|
||||
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
|
||||
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
|
||||
)
|
||||
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}"
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "===== end census @${atSeconds}s =====")
|
||||
Log.d(TAG) { "===== end census @${atSeconds}s =====" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Buckets rendered highest-count first — the shape both census summary lines want. */
|
||||
private fun Map<String, Int>.byCountDesc() = entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" }
|
||||
|
||||
+12
@@ -44,6 +44,10 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -111,6 +115,14 @@ val NotificationsPerKeyKinds2 =
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitPullRequestUpdateEvent.KIND,
|
||||
// NIP-34 status events (1630/1631/1632/1633): opened, applied/merged,
|
||||
// closed, drafted. The status author p-tags every prior participant of
|
||||
// the target patch/PR/issue, so a `#p`=me subscription surfaces
|
||||
// "someone merged/closed a thread I'm on" without a repo-scoped query.
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
|
||||
+3
-2
@@ -153,8 +153,9 @@ object BlossomPaymentHandler {
|
||||
|
||||
val check = checkAmount(payment, shownSats)
|
||||
if (check !is AmountCheck.Ok) {
|
||||
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
|
||||
return PayResult.Refused(refusalReason(check))
|
||||
val reason = refusalReason(check)
|
||||
Log.w("BlossomPayment") { "refusing invoice: $reason" }
|
||||
return PayResult.Refused(reason)
|
||||
}
|
||||
|
||||
// Never send the same invoice twice: an earlier attempt may still settle.
|
||||
|
||||
@@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import androidx.collection.mutableScatterMapOf
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -54,7 +56,53 @@ fun <T> CrossfadeIfEnabled(
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Latches the first time a crossfade's target moves off the value it was composed with. */
|
||||
private class ChangeLatch {
|
||||
var changed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there
|
||||
* is something to animate.
|
||||
*
|
||||
* `updateTransition` allocates a transition, its animation list and its seeking state on *first
|
||||
* composition*, even though first composition has nothing to cross-fade — target and initial state
|
||||
* are the same value. In a feed that is waste: every card scrolled in builds a transition per
|
||||
* animated element, and during a scroll essentially none of them run, because the underlying counts
|
||||
* and icons do not change in the second a card is on screen.
|
||||
*
|
||||
* So the plain content renders until the target actually moves. At that point the transition is
|
||||
* built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at
|
||||
* the new one, so the first real change still animates exactly as before; every later change
|
||||
* animates through the now-live transition normally.
|
||||
*/
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun <T> DeferredCrossfade(
|
||||
targetState: T,
|
||||
modifier: Modifier,
|
||||
contentAlignment: Alignment,
|
||||
animationSpec: FiniteAnimationSpec<Float>,
|
||||
label: String,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val initial = remember { targetState }
|
||||
val latch = remember { ChangeLatch() }
|
||||
if (targetState != initial) latch.changed = true
|
||||
|
||||
if (!latch.changed) {
|
||||
Box(modifier, contentAlignment) {
|
||||
content(targetState)
|
||||
}
|
||||
} else {
|
||||
val transitionState = remember { MutableTransitionState(initial) }
|
||||
transitionState.targetState = targetState
|
||||
val transition = rememberTransition(transitionState, label)
|
||||
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -175,7 +175,7 @@ fun EditPostView(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.imePadding()
|
||||
.imePaddingSafe()
|
||||
.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
@@ -57,10 +58,11 @@ object MediaSaverToDisk {
|
||||
resolveBlossom: suspend (String) -> String? = { null },
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) {
|
||||
// No dispatch here: save() and downloadAndSave() both move themselves to IO.
|
||||
when {
|
||||
videoUri.isNullOrBlank() -> {
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
|
||||
videoUri.startsWith("file") -> {
|
||||
@@ -131,18 +133,25 @@ object MediaSaverToDisk {
|
||||
}
|
||||
|
||||
val trimmedUrl = trimInlineMetaData(downloadUrl)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val headerType =
|
||||
response
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.trim()
|
||||
val headerType =
|
||||
response
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.trim()
|
||||
|
||||
val realType =
|
||||
headerType?.takeIf(::isSaveableMimeType)
|
||||
?: mimeType?.takeIf(::isSaveableMimeType)
|
||||
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
|
||||
?: ""
|
||||
// Resolved for both paths: the API level decides how the file is
|
||||
// written, never which directory it belongs in.
|
||||
val realType =
|
||||
headerType?.takeIf(::isSaveableMimeType)
|
||||
?: mimeType?.takeIf(::isSaveableMimeType)
|
||||
?: getMimeTypeFromExtension(trimmedUrl).takeIf(::isSaveableMimeType)
|
||||
?: ""
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Deliberately Q-only: MediaStore refuses an insert without a usable
|
||||
// type, so there is nothing to do but report it. The legacy path has
|
||||
// always written whatever it downloaded and still does - an unresolved
|
||||
// type lands in Downloads, which accepts any file.
|
||||
check(realType.isNotBlank()) { "Can't find out the content type" }
|
||||
|
||||
saveContentQ(
|
||||
@@ -154,6 +163,7 @@ object MediaSaverToDisk {
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = File(trimmedUrl).name,
|
||||
contentType = realType,
|
||||
contentSource = response.body.source(),
|
||||
context = context,
|
||||
)
|
||||
@@ -176,44 +186,53 @@ object MediaSaverToDisk {
|
||||
private fun isSaveableMimeType(type: String): Boolean =
|
||||
type.isNotBlank() &&
|
||||
(
|
||||
type.startsWith("image/", ignoreCase = true) ||
|
||||
type.startsWith("video/", ignoreCase = true) ||
|
||||
type.startsWith("audio/", ignoreCase = true) ||
|
||||
MediaStoreTarget.of(type) != MediaStoreTarget.DOWNLOADS ||
|
||||
type.equals(PDF_MIME_TYPE, ignoreCase = true)
|
||||
)
|
||||
|
||||
/**
|
||||
* Copies a local file into the gallery. Suspending and dispatched to IO like
|
||||
* [downloadAndSave]: callers reach this from click handlers, and a
|
||||
* storage-permission callback among them launches on the main dispatcher,
|
||||
* where copying a whole video would block the UI thread.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun save(
|
||||
suspend fun save(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
try {
|
||||
val extension =
|
||||
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = Uuid.random().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = "${Uuid.random()}.$extension",
|
||||
contentSource = buffer,
|
||||
context = context,
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// use{}: readAll leaves its source open, so without this the file
|
||||
// descriptor stays open until the finalizer runs.
|
||||
localFile.inputStream().source().buffer().use { buffer ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
displayName = Uuid.random().toString(),
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
contentResolver = context.contentResolver,
|
||||
)
|
||||
} else {
|
||||
val extension =
|
||||
mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
saveContentDefault(
|
||||
fileName = "${Uuid.random()}.$extension",
|
||||
contentType = mimeType ?: "",
|
||||
contentSource = buffer,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MediaSaverToDisk", "Unable to save", e)
|
||||
onError(e)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MediaSaverToDisk", "Unable to save", e)
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,29 +244,7 @@ object MediaSaverToDisk {
|
||||
contentResolver: ContentResolver,
|
||||
) {
|
||||
val cleanMimeType = normalizeMimeTypeForMediaStore(contentType.substringBefore(";").trim())
|
||||
|
||||
val (masterUri, baseDir) =
|
||||
when {
|
||||
cleanMimeType.startsWith("image/", ignoreCase = true) -> {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
|
||||
cleanMimeType.startsWith("audio/", ignoreCase = true) -> {
|
||||
// Audio content goes into the Music MediaStore + folder. Routing it through
|
||||
// Video.EXTERNAL_CONTENT_URI (the previous fall-through behavior) crashes
|
||||
// with IllegalArgumentException because MediaProvider rejects audio/* into
|
||||
// the Video collection.
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_MUSIC
|
||||
}
|
||||
|
||||
cleanMimeType.equals(PDF_MIME_TYPE, ignoreCase = true) -> {
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_DOWNLOADS
|
||||
}
|
||||
|
||||
else -> {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI to Environment.DIRECTORY_PICTURES
|
||||
}
|
||||
}
|
||||
val target = MediaStoreTarget.of(cleanMimeType)
|
||||
|
||||
val contentValues =
|
||||
ContentValues().apply {
|
||||
@@ -255,11 +252,11 @@ object MediaSaverToDisk {
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, cleanMimeType)
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
baseDir + File.separatorChar + AMETHYST_SUBDIRECTORY,
|
||||
target.relativeDirectory + File.separatorChar + AMETHYST_SUBDIRECTORY,
|
||||
)
|
||||
}
|
||||
|
||||
val uri = contentResolver.insert(masterUri, contentValues)
|
||||
val uri = contentResolver.insert(target.collectionUri(), contentValues)
|
||||
checkNotNull(uri) { "Can't insert the new content" }
|
||||
|
||||
try {
|
||||
@@ -276,12 +273,15 @@ object MediaSaverToDisk {
|
||||
|
||||
private fun saveContentDefault(
|
||||
fileName: String,
|
||||
contentType: String,
|
||||
contentSource: BufferedSource,
|
||||
context: Context,
|
||||
) {
|
||||
val baseDir = MediaStoreTarget.of(contentType).relativeDirectory
|
||||
|
||||
val subdirectory =
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
Environment.getExternalStoragePublicDirectory(baseDir),
|
||||
AMETHYST_SUBDIRECTORY,
|
||||
).apply {
|
||||
if (!exists()) mkdirs()
|
||||
@@ -307,6 +307,60 @@ object MediaSaverToDisk {
|
||||
else -> mimeType
|
||||
}
|
||||
|
||||
/**
|
||||
* The MediaStore collection a download is filed under, together with the public
|
||||
* directory it is written to.
|
||||
*
|
||||
* MediaProvider validates the primary directory of [MediaStore.MediaColumns.RELATIVE_PATH]
|
||||
* against the collection being inserted into and rejects a mismatch with
|
||||
* `IllegalArgumentException: Primary directory Pictures not allowed for
|
||||
* content://media/external/video/media; allowed directories are [DCIM, Movies]`.
|
||||
* A collection usually accepts more than one directory; these are the ones Amethyst
|
||||
* files under.
|
||||
*/
|
||||
internal enum class MediaStoreTarget(
|
||||
val relativeDirectory: String,
|
||||
) {
|
||||
// The directory names are the values of Environment.DIRECTORY_PICTURES, _MUSIC,
|
||||
// _MOVIES and _DOWNLOADS. They are spelled out because those are plain static
|
||||
// fields that the unit-test android.jar leaves null, which would make this
|
||||
// mapping impossible to cover off-device. MediaStoreTargetInstrumentedTest pins
|
||||
// them back to the platform constants on-device.
|
||||
IMAGES("Pictures"),
|
||||
AUDIO("Music"),
|
||||
VIDEO("Movies"),
|
||||
DOWNLOADS("Download"),
|
||||
;
|
||||
|
||||
/**
|
||||
* Has to stay a method. The EXTERNAL_CONTENT_URI fields are null under the same
|
||||
* unit-test android.jar, and MediaStore.Downloads only exists from API 29, so
|
||||
* reading them from the constructor would break class init off-device and below Q.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun collectionUri(): Uri =
|
||||
when (this) {
|
||||
IMAGES -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
AUDIO -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
DOWNLOADS -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* PDFs, and anything that isn't image, audio or video content, go to Downloads
|
||||
* — the one collection that accepts every kind of file.
|
||||
*/
|
||||
fun of(mimeType: String): MediaStoreTarget =
|
||||
when {
|
||||
mimeType.startsWith("image/", ignoreCase = true) -> IMAGES
|
||||
mimeType.startsWith("audio/", ignoreCase = true) -> AUDIO
|
||||
mimeType.startsWith("video/", ignoreCase = true) -> VIDEO
|
||||
else -> DOWNLOADS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
|
||||
private const val PDF_MIME_TYPE = "application/pdf"
|
||||
private const val BLOSSOM_SCHEME = "blossom:"
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.SettingSwitchItem
|
||||
@@ -127,7 +127,7 @@ fun NewMediaView(
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
|
||||
+2
-2
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -56,6 +55,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -108,7 +108,7 @@ fun NewUserMetadataScreen(
|
||||
top = pad.calculateTopPadding(),
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
).consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
|
||||
|
||||
+2
-2
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -56,6 +55,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -117,7 +117,7 @@ fun Bolt12OffersScaffold(
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
+2
-2
@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -51,6 +50,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -102,7 +102,7 @@ fun MediaServersScaffold(
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -334,7 +334,7 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
// again must not be able to spin up an endless pay-prompt
|
||||
// cycle. One prompt per target per user-initiated mirror.
|
||||
if (!promptLedger.shouldPrompt(row.hash, target)) {
|
||||
Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting")
|
||||
Log.w("BlossomBlobManager") { "mirror to $target asked for payment again after being paid; not re-prompting" }
|
||||
_error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice."
|
||||
continue
|
||||
}
|
||||
|
||||
+2
-2
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -56,6 +55,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -132,7 +132,7 @@ private fun NestsServersScaffold(
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
+2
-2
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -54,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -116,7 +116,7 @@ fun PaymentTargetsScaffold(
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
.imePaddingSafe(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
@@ -105,9 +105,13 @@ class CallActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
val callManager = CallSessionBridge.callManager
|
||||
// The session deliberately binds to the account, not to the AccountViewModel: MainActivity
|
||||
// (and with it the ViewModel) can be destroyed while this call is running, so a session
|
||||
// built on the ViewModel would die with it. The ViewModel below is only for the UI.
|
||||
val account = CallSessionBridge.account
|
||||
val accountViewModel = CallSessionBridge.accountViewModel
|
||||
|
||||
if (callManager == null || accountViewModel == null) {
|
||||
if (callManager == null || account == null || accountViewModel == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -118,10 +122,10 @@ class CallActivity : AppCompatActivity() {
|
||||
context = this,
|
||||
callManager = callManager,
|
||||
scope = lifecycleScope,
|
||||
publishWrap = { wrap -> accountViewModel.account.publishCallSignaling(wrap) },
|
||||
signerProvider = { accountViewModel.account.signer },
|
||||
localPubKey = accountViewModel.account.signer.pubKey,
|
||||
settingsProvider = { accountViewModel.account.settings },
|
||||
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
|
||||
signerProvider = { account.signer },
|
||||
localPubKey = account.signer.pubKey,
|
||||
settingsProvider = { account.settings },
|
||||
)
|
||||
session = callSession
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.call
|
||||
|
||||
import android.app.Activity
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -74,6 +78,13 @@ fun CallScreen(
|
||||
val callState by callManager.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val mediaProjectionManager = remember(context) { context.getSystemService(MediaProjectionManager::class.java) }
|
||||
val screenShareLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.let { callSession?.startScreenShare(it) }
|
||||
}
|
||||
}
|
||||
val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) }
|
||||
val errorMessage by (callSession?.errorMessage ?: emptyStringFlow).collectAsState()
|
||||
|
||||
@@ -162,6 +173,10 @@ fun CallScreen(
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
onToggleMute = { callSession?.toggleMute() },
|
||||
onToggleVideo = { callSession?.toggleVideo() },
|
||||
onStartScreenShare = {
|
||||
mediaProjectionManager?.createScreenCaptureIntent()?.let(screenShareLauncher::launch)
|
||||
},
|
||||
onStopScreenShare = { callSession?.stopScreenShare() },
|
||||
onCycleAudioRoute = { callSession?.cycleAudioRoute() },
|
||||
onInvitePeer = { peerPubKey -> callSession?.invitePeer(peerPubKey) },
|
||||
)
|
||||
|
||||
@@ -77,6 +77,8 @@ fun ConnectedCallUI(
|
||||
onHangup: () -> Unit,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleVideo: () -> Unit,
|
||||
onStartScreenShare: () -> Unit,
|
||||
onStopScreenShare: () -> Unit,
|
||||
onCycleAudioRoute: () -> Unit,
|
||||
onInvitePeer: (String) -> Unit = {},
|
||||
) {
|
||||
@@ -99,11 +101,13 @@ fun ConnectedCallUI(
|
||||
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
|
||||
val isAudioMuted by (callSession?.isAudioMuted ?: defaultFalse).collectAsState()
|
||||
val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultTrue).collectAsState()
|
||||
val isScreenSharing by (callSession?.isScreenSharing ?: defaultFalse).collectAsState()
|
||||
val isFrontCamera by (callSession?.isFrontCamera ?: defaultTrue).collectAsState()
|
||||
val currentAudioRoute by (callSession?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
|
||||
val hasActiveVideo =
|
||||
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
|
||||
isVideoEnabled ||
|
||||
isScreenSharing ||
|
||||
remoteVideoTracks.isNotEmpty()
|
||||
|
||||
var showAddParticipant by remember { mutableStateOf(false) }
|
||||
@@ -142,7 +146,7 @@ fun ConnectedCallUI(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
if (isVideoEnabled) {
|
||||
if (isVideoEnabled && !isScreenSharing) {
|
||||
localVideoTrack?.let { track ->
|
||||
VideoRenderer(
|
||||
videoTrack = track,
|
||||
@@ -159,16 +163,27 @@ fun ConnectedCallUI(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = formatDuration(elapsed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 14.sp,
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(top = 16.dp),
|
||||
)
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (isScreenSharing) {
|
||||
Text(
|
||||
text = stringRes(R.string.call_screen_sharing),
|
||||
color = Color.Cyan,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatDuration(elapsed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Voice-only path: render the same peer grid so each pending
|
||||
// callee shows their individual "Calling…" status rather than a
|
||||
@@ -205,10 +220,13 @@ fun ConnectedCallUI(
|
||||
CallControls(
|
||||
isAudioMuted = isAudioMuted,
|
||||
isVideoEnabled = isVideoEnabled,
|
||||
isScreenSharing = isScreenSharing,
|
||||
isFrontCamera = isFrontCamera,
|
||||
currentAudioRoute = currentAudioRoute,
|
||||
onToggleMute = onToggleMute,
|
||||
onToggleVideo = onToggleVideo,
|
||||
onStartScreenShare = onStartScreenShare,
|
||||
onStopScreenShare = onStopScreenShare,
|
||||
onSwitchCamera = { callSession?.switchCamera() },
|
||||
onCycleAudioRoute = onCycleAudioRoute,
|
||||
onAddParticipant = { showAddParticipant = true },
|
||||
@@ -226,10 +244,13 @@ fun ConnectedCallUI(
|
||||
private fun CallControls(
|
||||
isAudioMuted: Boolean,
|
||||
isVideoEnabled: Boolean,
|
||||
isScreenSharing: Boolean,
|
||||
isFrontCamera: Boolean,
|
||||
currentAudioRoute: AudioRoute,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleVideo: () -> Unit,
|
||||
onStartScreenShare: () -> Unit,
|
||||
onStopScreenShare: () -> Unit,
|
||||
onSwitchCamera: () -> Unit,
|
||||
onCycleAudioRoute: () -> Unit,
|
||||
onAddParticipant: () -> Unit,
|
||||
@@ -252,15 +273,38 @@ private fun CallControls(
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleVideo, modifier = Modifier.size(56.dp)) {
|
||||
IconButton(
|
||||
onClick = onToggleVideo,
|
||||
enabled = !isScreenSharing,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (isVideoEnabled) MaterialSymbols.Videocam else MaterialSymbols.VideocamOff,
|
||||
contentDescription = stringRes(if (isVideoEnabled) R.string.call_camera_off else R.string.call_camera_on),
|
||||
tint = if (!isVideoEnabled) Color.Red else Color.White,
|
||||
tint =
|
||||
when {
|
||||
isScreenSharing -> Color.Gray
|
||||
!isVideoEnabled -> Color.Red
|
||||
else -> Color.White
|
||||
},
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
if (isVideoEnabled) {
|
||||
IconButton(
|
||||
onClick = if (isScreenSharing) onStopScreenShare else onStartScreenShare,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Cast,
|
||||
contentDescription =
|
||||
stringRes(
|
||||
if (isScreenSharing) R.string.call_screen_share_stop else R.string.call_screen_share_start,
|
||||
),
|
||||
tint = if (isScreenSharing) Color.Cyan else Color.White,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
if (isVideoEnabled && !isScreenSharing) {
|
||||
IconButton(onClick = onSwitchCamera, modifier = Modifier.size(56.dp)) {
|
||||
Icon(
|
||||
symbol =
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.AnswerRouteAction
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
@@ -60,6 +61,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.RtpSender
|
||||
import org.webrtc.VideoTrack
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -108,6 +110,7 @@ class CallSession(
|
||||
private val _isAudioMuted = MutableStateFlow(false)
|
||||
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
|
||||
val isVideoEnabled: StateFlow<Boolean> = mediaManager.isVideoEnabled
|
||||
val isScreenSharing: StateFlow<Boolean> = mediaManager.isScreenSharing
|
||||
val isFrontCamera: StateFlow<Boolean> = mediaManager.isFrontCamera
|
||||
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
|
||||
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
|
||||
@@ -117,7 +120,7 @@ class CallSession(
|
||||
@Volatile private var closed = false
|
||||
|
||||
@Volatile private var foregroundServiceStarted = false
|
||||
private val videoSenders = ConcurrentHashMap<HexKey, org.webrtc.RtpSender>()
|
||||
private val videoSenders = ConcurrentHashMap<HexKey, RtpSender>()
|
||||
|
||||
private val pendingRenegotiation = ConcurrentHashMap<HexKey, Boolean>()
|
||||
|
||||
@@ -142,6 +145,12 @@ class CallSession(
|
||||
// ---- Initialization ----
|
||||
|
||||
init {
|
||||
mediaManager.onScreenShareEnded = {
|
||||
if (!closed) {
|
||||
scope.launch { stopScreenShareNow() }
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all session-level events (answers, ICE candidates,
|
||||
// peer joins/leaves, mid-call offers) from the single SharedFlow.
|
||||
scope.launch {
|
||||
@@ -227,6 +236,7 @@ class CallSession(
|
||||
|
||||
scope.launch {
|
||||
audioManager.isNearEar.collect { nearEar ->
|
||||
if (mediaManager.isScreenSharing.value) return@collect
|
||||
val videoTrack = mediaManager.localVideoTrack ?: return@collect
|
||||
if (nearEar && mediaManager.isVideoEnabled.value && !videoPausedByProximity) {
|
||||
videoPausedByProximity = true
|
||||
@@ -530,6 +540,79 @@ class CallSession(
|
||||
|
||||
// ---- UI toggle controls ----
|
||||
|
||||
fun startScreenShare(permissionData: Intent) {
|
||||
val state = callManager.state.value
|
||||
if (closed || (state !is CallState.Connecting && state !is CallState.Connected)) return
|
||||
|
||||
scope.launch {
|
||||
if (mediaManager.isScreenSharing.value) return@launch
|
||||
try {
|
||||
ensureForegroundService(isScreenSharing = true)
|
||||
val screenTrack = withContext(Dispatchers.IO) { mediaManager.startScreenShare(permissionData) }
|
||||
if (closed) {
|
||||
stopScreenShareNow()
|
||||
return@launch
|
||||
}
|
||||
replaceVideoTrack(screenTrack)
|
||||
updateForegroundServiceNotification(isScreenSharing = true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start screen share", e)
|
||||
if (mediaManager.isScreenSharing.value) {
|
||||
try {
|
||||
stopScreenShareNow()
|
||||
} catch (cleanupError: Exception) {
|
||||
Log.e(TAG, "Failed to clean up screen share", cleanupError)
|
||||
}
|
||||
}
|
||||
_errorMessage.value = "Failed to share screen: ${e.message}"
|
||||
updateForegroundServiceNotification(isScreenSharing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopScreenShare() {
|
||||
scope.launch { stopScreenShareNow() }
|
||||
}
|
||||
|
||||
private fun stopScreenShareNow() {
|
||||
val resources = mediaManager.stopScreenShare() ?: return
|
||||
try {
|
||||
val restoredTrack =
|
||||
mediaManager.localVideoTrack.takeIf { mediaManager.isVideoEnabled.value }
|
||||
replaceVideoTrack(restoredTrack)
|
||||
if (videoPausedByProximity) {
|
||||
mediaManager.localVideoTrack?.setEnabled(false)
|
||||
mediaManager.stopCamera()
|
||||
}
|
||||
} finally {
|
||||
mediaManager.disposeScreenShareResources(resources)
|
||||
updateForegroundServiceNotification(isScreenSharing = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceVideoTrack(track: VideoTrack?) {
|
||||
peerSessionMgr.allSessionKeys().forEach { key ->
|
||||
val session = webRtcSession(key) ?: return@forEach
|
||||
if (track != null) {
|
||||
val sender = videoSenders[key]
|
||||
if (sender != null) {
|
||||
if (!sender.setTrack(track, false)) {
|
||||
Log.e(TAG) { "Failed to replace video track for ${key.take(8)}" }
|
||||
}
|
||||
} else {
|
||||
session.addTrack(track, settingsProvider().callMaxBitrateBps)?.let { newSender ->
|
||||
videoSenders[key] = newSender
|
||||
}
|
||||
}
|
||||
} else {
|
||||
videoSenders.remove(key)?.let { sender ->
|
||||
sender.setTrack(null, false)
|
||||
session.removeTrack(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
val muted = !_isAudioMuted.value
|
||||
_isAudioMuted.value = muted
|
||||
@@ -537,6 +620,7 @@ class CallSession(
|
||||
}
|
||||
|
||||
fun toggleVideo() {
|
||||
if (mediaManager.isScreenSharing.value) return
|
||||
val enabling = !mediaManager.isVideoEnabled.value
|
||||
if (enabling) {
|
||||
if (mediaManager.localVideoTrack == null) {
|
||||
@@ -569,6 +653,7 @@ class CallSession(
|
||||
}
|
||||
|
||||
fun switchCamera() {
|
||||
if (mediaManager.isScreenSharing.value) return
|
||||
mediaManager.switchCamera()
|
||||
}
|
||||
|
||||
@@ -651,7 +736,7 @@ class CallSession(
|
||||
throw e
|
||||
}
|
||||
mediaManager.localAudioTrack?.let { session.addTrack(it) }
|
||||
mediaManager.localVideoTrack?.let { track ->
|
||||
(mediaManager.screenVideoTrack ?: mediaManager.localVideoTrack)?.let { track ->
|
||||
val sender = session.addTrack(track, settingsProvider().callMaxBitrateBps)
|
||||
if (sender != null) {
|
||||
videoSenders[peerPubKey] = sender
|
||||
@@ -702,6 +787,7 @@ class CallSession(
|
||||
Log.d(TAG) { "close() called on ${Thread.currentThread().name} state=${callManager.state.value::class.simpleName}" }
|
||||
// Signal the init collectors to stop touching resources.
|
||||
closed = true
|
||||
mediaManager.onScreenShareEnded = null
|
||||
|
||||
try {
|
||||
audioManager.stopRinging()
|
||||
@@ -755,13 +841,16 @@ class CallSession(
|
||||
|
||||
// ---- Foreground service ----
|
||||
|
||||
private fun ensureForegroundService() {
|
||||
if (foregroundServiceStarted) return
|
||||
private fun ensureForegroundService(isScreenSharing: Boolean = mediaManager.isScreenSharing.value) {
|
||||
if (foregroundServiceStarted) {
|
||||
updateForegroundServiceNotification(isScreenSharing)
|
||||
return
|
||||
}
|
||||
foregroundServiceStarted = true
|
||||
startForegroundService()
|
||||
startForegroundService(isScreenSharing)
|
||||
}
|
||||
|
||||
private fun startForegroundService() {
|
||||
private fun startForegroundService(isScreenSharing: Boolean = mediaManager.isScreenSharing.value) {
|
||||
try {
|
||||
val peerName = callManager.currentPeerPubKey() ?: ""
|
||||
val isVideo = mediaManager.isVideoEnabled.value
|
||||
@@ -771,7 +860,11 @@ class CallSession(
|
||||
action = CallForegroundService.ACTION_START
|
||||
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
|
||||
putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo)
|
||||
putExtra(CallForegroundService.EXTRA_IS_SCREEN_SHARING, isScreenSharing)
|
||||
putExtra(CallForegroundService.EXTRA_IS_RINGING, isRinging)
|
||||
if (isScreenSharing) {
|
||||
putExtra(CallForegroundService.EXTRA_STATUS_TEXT, context.getString(R.string.call_screen_sharing))
|
||||
}
|
||||
}
|
||||
context.startForegroundService(intent)
|
||||
} catch (e: Exception) {
|
||||
@@ -779,7 +872,7 @@ class CallSession(
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateForegroundServiceNotification() {
|
||||
private fun updateForegroundServiceNotification(isScreenSharing: Boolean = mediaManager.isScreenSharing.value) {
|
||||
if (!foregroundServiceStarted) return
|
||||
try {
|
||||
val peerName = callManager.currentPeerPubKey() ?: ""
|
||||
@@ -790,7 +883,11 @@ class CallSession(
|
||||
action = CallForegroundService.ACTION_UPDATE
|
||||
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
|
||||
putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo)
|
||||
putExtra(CallForegroundService.EXTRA_IS_SCREEN_SHARING, isScreenSharing)
|
||||
putExtra(CallForegroundService.EXTRA_IS_RINGING, isRinging)
|
||||
if (isScreenSharing) {
|
||||
putExtra(CallForegroundService.EXTRA_STATUS_TEXT, context.getString(R.string.call_screen_sharing))
|
||||
}
|
||||
}
|
||||
context.startService(intent)
|
||||
} catch (e: Exception) {
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* 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 android.Manifest
|
||||
import android.os.Build
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
// Chrome shared by the full-screen media viewers -- the zoomable image/video dialog and the PDF
|
||||
// viewer. Both are opened the same way (tap a media card in a feed), so they immerse, auto-hide,
|
||||
// and lay their controls out identically.
|
||||
|
||||
// Opening churn -- insets arriving, then the bars being hidden -- must not look like a user
|
||||
// gesture, so the row snaps through it and only animates afterwards.
|
||||
private const val CONTROLS_SETTLE_BEFORE_ANIMATING_MS = 350L
|
||||
|
||||
// Roughly the system bars' own show/hide duration, so the row travels with them rather than
|
||||
// trailing after they have already arrived.
|
||||
private const val CONTROLS_SLIDE_MS = 200
|
||||
|
||||
// Keeps the row off the screen edge -- and off the rounded corners -- while the bars are hidden.
|
||||
private val VIEWER_CHROME_EDGE_GAP = 16.dp
|
||||
|
||||
// How long the controls stay up before the viewer fades them out on its own.
|
||||
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 2000L
|
||||
|
||||
/**
|
||||
* Goes fully immersive for as long as the viewer is on screen: hides both OS bars and restores them
|
||||
* on the way out. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user swipe to peek the bars back.
|
||||
*/
|
||||
@Composable
|
||||
fun ImmersiveSystemBarsEffect(window: Window?) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(window, view) {
|
||||
val controller = window?.let { WindowInsetsControllerCompat(it, view) }
|
||||
controller?.apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility of the viewer controls: they start on screen, fade out on their own after
|
||||
* [CONTROLS_AUTO_HIDE_DELAY_MS], and the caller flips the returned state on tap.
|
||||
*
|
||||
* [holdOpen] freezes the timer while something anchored to the controls -- the share sheet, say --
|
||||
* is up, and re-arms it once that closes. [armed] withholds the countdown until there is something
|
||||
* to look at, so a viewer that spends three seconds fetching its media doesn't reveal the first
|
||||
* frame with the controls already gone.
|
||||
*
|
||||
* A tap that brings the controls back deliberately gets no timer: the user asked for them, so they
|
||||
* stay until tapped away. That is why the countdown races the controls going away rather than just
|
||||
* sleeping -- a timer left over from an earlier show would otherwise wipe controls the user tapped
|
||||
* back up in the meantime.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberViewerControlsVisibility(
|
||||
holdOpen: Boolean,
|
||||
armed: Boolean = true,
|
||||
): MutableState<Boolean> {
|
||||
val visible = remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(armed, holdOpen) {
|
||||
if (!armed || holdOpen) return@LaunchedEffect
|
||||
|
||||
val hiddenFirst =
|
||||
withTimeoutOrNull(CONTROLS_AUTO_HIDE_DELAY_MS) {
|
||||
snapshotFlow { visible.value }.first { !it }
|
||||
}
|
||||
|
||||
if (hiddenFirst == null) visible.value = false
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the viewer chrome sits from a screen edge: the system bar's own height while the bar is
|
||||
* on screen, and a thin constant once it is hidden -- so the chrome follows the bar instead of
|
||||
* reserving space for one that is not there.
|
||||
*
|
||||
* This only works because the viewer asks for BEHAVIOR_DEFAULT rather than transient bars. Under
|
||||
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE a peeked bar is painted over the content and dispatches no
|
||||
* insets at all -- `systemBars` stays 0 and `isVisible` stays false the whole time it is on screen
|
||||
* -- so nothing here could react to it.
|
||||
*
|
||||
* The value is animated, but snapped for [CONTROLS_SETTLE_BEFORE_ANIMATING_MS] after the chrome
|
||||
* appears. Opening moves the inset twice for reasons the user did not cause: the window has not
|
||||
* been told its insets yet (they read 0), and ImmersiveSystemBarsEffect hides the bars from a
|
||||
* DisposableEffect that runs after composition. Animating either would play a slide on open.
|
||||
*/
|
||||
@Composable
|
||||
fun animatedViewerChromeInset(atBottom: Boolean): Dp {
|
||||
val density = LocalDensity.current
|
||||
val bars = WindowInsets.systemBars
|
||||
val barPx = if (atBottom) bars.getBottom(density) else bars.getTop(density)
|
||||
val target = with(density) { maxOf(barPx, VIEWER_CHROME_EDGE_GAP.roundToPx()).toDp() }
|
||||
|
||||
var settled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(CONTROLS_SETTLE_BEFORE_ANIMATING_MS)
|
||||
settled = true
|
||||
}
|
||||
|
||||
val animated by animateDpAsState(
|
||||
targetValue = target,
|
||||
animationSpec = if (settled) tween(durationMillis = CONTROLS_SLIDE_MS) else snap(),
|
||||
label = "viewerChromeInset",
|
||||
)
|
||||
return animated
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a viewer control row along a screen edge -- the top by default, the bottom when [atBottom].
|
||||
*
|
||||
* The viewer hides the system bars, so the row would otherwise sit against the screen edge. It
|
||||
* takes its distance from [animatedViewerChromeInset], which follows the bar on and off screen
|
||||
* rather than permanently reserving room for it.
|
||||
*
|
||||
* Horizontal display-cutout insets are still applied outright: a landscape notch eats into the
|
||||
* sides whatever the bars are doing. The top cutout is deliberately not applied, because on a
|
||||
* punch-hole device it is a centred hole that the edge-anchored buttons are nowhere near -- and
|
||||
* honouring it as a full-width top inset would push them down by the height of a camera they do
|
||||
* not overlap.
|
||||
*
|
||||
* The row also holds a button's height whatever it carries, so content that outlives the buttons
|
||||
* doesn't shift as they come and go.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun ViewerControlsRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = spacedBy(Size10dp),
|
||||
atBottom: Boolean = false,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
// systemBars is visibility-aware under BEHAVIOR_DEFAULT: 0 while the bars are hidden, and the
|
||||
// real bar size once the user swipes them in -- so the controls follow them instead of sitting
|
||||
// under a bar or reserving space for one that is not there. The 16dp floor keeps them clear of
|
||||
// the rounded corners while hidden. Animated so the row slides rather than jumps.
|
||||
val animatedInset = animatedViewerChromeInset(atBottom)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal))
|
||||
.padding(
|
||||
top = if (atBottom) 0.dp else animatedInset,
|
||||
bottom = if (atBottom) animatedInset else 0.dp,
|
||||
).padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = ButtonDefaults.MinHeight),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** Leaves the viewer. Always the first item in the control row. */
|
||||
@Composable
|
||||
fun ViewerBackButton(onDismiss: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the share sheet for whatever the viewer is showing. The sheet anchors to the button. */
|
||||
@Composable
|
||||
fun ViewerShareButton(
|
||||
content: BaseMediaContent,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { popupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
content = content,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the media to the gallery. Q and up write through MediaStore and need no permission; older
|
||||
* releases ask for WRITE_EXTERNAL_STORAGE first and save as soon as it is granted.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
fun ViewerSaveToGalleryButton(
|
||||
content: BaseMediaContent,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// The application context and the view model's scope, never the composition's: this button
|
||||
// lives inside the AnimatedVisibility that the auto-hide collapses two seconds after the tap
|
||||
// that started the download, and a rememberCoroutineScope job would be cancelled with it --
|
||||
// killing the save with no file and no error. Matches the download row in ShareMediaAction.
|
||||
val localContext = LocalContext.current.applicationContext
|
||||
val scope = accountViewModel.viewModelScope
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
scope.launch {
|
||||
saveMediaToGallery(content, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
saveMediaToGallery(content, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.download_to_phone),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
@@ -61,14 +60,15 @@ fun SlidingCarousel(
|
||||
) {
|
||||
HorizontalPager(state = pagerState) { page -> itemContent(page) }
|
||||
|
||||
// you can remove the surface in case you don't want
|
||||
// the transparent bacground
|
||||
// The dots ride the same animated inset as the rest of the viewer chrome, so they slide
|
||||
// with the navigation bar instead of snapping the moment it appears. navigationBarsPadding()
|
||||
// tracks the bar correctly but moves in one frame, which reads as a jump next to the
|
||||
// controls sliding at the top.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 8.dp),
|
||||
.padding(bottom = animatedViewerChromeInset(atBottom = true) + 8.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
) {
|
||||
|
||||
+11
-145
@@ -20,9 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.WindowManager
|
||||
@@ -35,31 +33,22 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -71,22 +60,13 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
|
||||
@@ -101,19 +81,11 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.ZoomState
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
@@ -211,18 +183,9 @@ fun ZoomableImageDialog(
|
||||
dialogWindow.attributes = attributes
|
||||
}
|
||||
|
||||
// Go fully immersive while the full-screen media viewer is open: hide both OS bars and
|
||||
// restore them when the dialog closes. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user
|
||||
// swipe to peek the bars. Applies to full-screen images and video alike (shared dialog).
|
||||
val dialogView = LocalView.current
|
||||
DisposableEffect(dialogWindow, dialogView) {
|
||||
val controller = dialogWindow?.let { WindowInsetsControllerCompat(it, dialogView) }
|
||||
controller?.apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen
|
||||
// images and video alike (shared dialog), and matches the PDF viewer.
|
||||
ImmersiveSystemBarsEffect(dialogWindow)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Background surface that fades in as the content grows to fullscreen.
|
||||
@@ -250,7 +213,6 @@ fun ZoomableImageDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
@@ -264,29 +226,13 @@ private fun DialogContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState { allImages.size }
|
||||
val controllerVisible = remember { mutableStateOf(true) }
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
val controllerVisible = rememberViewerControlsVisibility(holdOpen = sharePopupExpanded.value)
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
delay(2000)
|
||||
if (!sharePopupExpanded.value) {
|
||||
controllerVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-trigger auto-hide after the share dialog is dismissed
|
||||
LaunchedEffect(sharePopupExpanded.value) {
|
||||
if (!sharePopupExpanded.value && controllerVisible.value) {
|
||||
delay(2000)
|
||||
controllerVisible.value = false
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,100 +336,20 @@ private fun DialogContent(
|
||||
// Also fade with the grow animation so controls appear/disappear alongside it.
|
||||
modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
ViewerControlsRow {
|
||||
ViewerBackButton(onDismiss)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
if (myContent is MediaUrlImage || myContent is MediaLocalImage) {
|
||||
OutlinedButton(
|
||||
onClick = { sharePopupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = sharePopupExpanded, myContent, onDismiss = { sharePopupExpanded.value = false })
|
||||
}
|
||||
ViewerShareButton(myContent, sharePopupExpanded, accountViewModel)
|
||||
}
|
||||
|
||||
val isPdfOrStaticImage = myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf
|
||||
val isNotLiveStream = myContent !is MediaUrlContent || !isHlsMedia(myContent.url, myContent.mimeType)
|
||||
if (isPdfOrStaticImage && isNotLiveStream) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
scope.launch {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.download_to_phone),
|
||||
)
|
||||
}
|
||||
ViewerSaveToGalleryButton(myContent, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
-77
@@ -23,24 +23,23 @@ package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -62,21 +61,22 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.createBitmap
|
||||
import coil3.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.ImmersiveSystemBarsEffect
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerBackButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerControlsRow
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerSaveToGalleryButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerShareButton
|
||||
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
|
||||
import com.vitorpamplona.amethyst.ui.components.rememberViewerControlsVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -106,6 +106,10 @@ private const val HI_RES_DEBOUNCE_MS = 200L
|
||||
// threshold region where we swap in the hi-res bitmap.
|
||||
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
|
||||
|
||||
// How long the page counter stays up on its own after a page turn, once the reader has hidden the
|
||||
// chrome. Long enough to read "7 / 24" without putting the controls back on top of the page.
|
||||
private const val PAGE_INDICATOR_FLASH_MS = 1500L
|
||||
|
||||
// How many recently-rendered pages to keep around. Pager already pre-composes the
|
||||
// current page plus one neighbor; this just speeds up small back/forward swipes.
|
||||
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
|
||||
@@ -165,6 +169,9 @@ fun PdfViewerDialog(
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
// Go fully immersive while the viewer is open, exactly like the image/video dialog.
|
||||
ImmersiveSystemBarsEffect(getDialogWindow())
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
|
||||
PdfViewerContent(
|
||||
content = content,
|
||||
@@ -219,31 +226,44 @@ private fun PdfViewerContent(
|
||||
}
|
||||
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = sharePopupExpanded,
|
||||
content = content,
|
||||
onDismiss = { sharePopupExpanded.value = false },
|
||||
)
|
||||
|
||||
val handle = handleState
|
||||
if (handle == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else if (handle.pageCount == 0) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Unable to open PDF",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val pagerState = rememberPagerState { handle.pageCount }
|
||||
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// A PDF that takes longer than the auto-hide delay to fetch would otherwise reveal its first
|
||||
// page with the chrome already gone, and nothing left to re-arm the timer.
|
||||
val controlsVisible =
|
||||
rememberViewerControlsVisibility(
|
||||
holdOpen = sharePopupExpanded.value,
|
||||
armed = handle != null,
|
||||
)
|
||||
|
||||
val pagerState = rememberPagerState { handle?.pageCount ?: 0 }
|
||||
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
|
||||
|
||||
// The page counter is wayfinding rather than a control, so it outlives the buttons for a
|
||||
// moment after every page turn -- a reader who tapped the chrome away still sees where a
|
||||
// swipe landed.
|
||||
var pageJustChanged by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
pageJustChanged = true
|
||||
delay(PAGE_INDICATOR_FLASH_MS)
|
||||
pageJustChanged = false
|
||||
}
|
||||
|
||||
val toggleControls = { if (!sharePopupExpanded.value) controlsVisible.value = !controlsVisible.value }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().clickable(onClick = toggleControls)) {
|
||||
if (handle == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else if (handle.pageCount == 0) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Unable to open PDF",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -252,53 +272,54 @@ private fun PdfViewerContent(
|
||||
handle = handle,
|
||||
pageIndex = pageIndex,
|
||||
cache = pageCache,
|
||||
// The zoomable page consumes the tap before the box underneath ever sees it,
|
||||
// so the toggle has to hang off the gesture detector that owns it.
|
||||
onTap = toggleControls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
// The buttons hold the top edge; the page counter lives along the bottom. Keeping them on
|
||||
// one strip meant the counter sat dead centre under the display cutout on punch-hole
|
||||
// devices, which is exactly where a front camera is. Down here it is clear of the cutout,
|
||||
// stays centred on the screen rather than on whatever space the asymmetric button groups
|
||||
// leave behind, and reads as wayfinding rather than as another control.
|
||||
ViewerControlsRow(modifier = Modifier.align(Alignment.TopCenter)) {
|
||||
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
|
||||
ViewerBackButton(onDismiss)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (handle != null) {
|
||||
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
|
||||
Row(horizontalArrangement = spacedBy(Size10dp)) {
|
||||
ViewerShareButton(content, sharePopupExpanded, accountViewModel)
|
||||
|
||||
ViewerSaveToGalleryButton(content, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||
color = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { sharePopupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
if (handle != null && handle.pageCount > 0) {
|
||||
ViewerControlsRow(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
atBottom = true,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible.value || pageJustChanged,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||
color = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -312,6 +333,7 @@ private fun PdfPageView(
|
||||
handle: PdfDocumentHandle,
|
||||
pageIndex: Int,
|
||||
cache: PageBitmapCache,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val cached = cache.get(pageIndex)
|
||||
|
||||
@@ -373,6 +395,7 @@ private fun PdfPageView(
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
zoomState = zoomState,
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { position ->
|
||||
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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.insets
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import java.util.WeakHashMap
|
||||
|
||||
/**
|
||||
* How long the animated IME inset may disagree with the IME's animation target, without emitting
|
||||
* anything new, before we call it stranded rather than in flight.
|
||||
*
|
||||
* A real IME animation reports a new inset every frame, so any value comfortably above a few
|
||||
* dropped frames never trips on one. In the stranded case nothing is emitted at all, so this is
|
||||
* purely how long the stale padding stays on screen before it is corrected — which is why this is
|
||||
* kept as tight as the frame cadence safely allows rather than padded "to be sure".
|
||||
*
|
||||
* The gap that actually binds this is not the frame cadence but the dead time between the target
|
||||
* flipping and the animation's first onProgress: until that first frame lands, "not started yet"
|
||||
* and "wedged" look identical. Measured on a Pixel 8 (120Hz) over 12 real Gboard transitions:
|
||||
* that dead time is 17-36ms (mean 24ms; closes 24-36ms, opens 17-23ms), after which every
|
||||
* subsequent frame lands within 11ms across a ~264ms animation. 120ms therefore clears the worst
|
||||
* observed start-up gap by ~3.3x while keeping the stale padding below the threshold where it
|
||||
* reads as lag. The earlier 400ms was ~11x that gap, and once a window wedges its IME animation
|
||||
* the full 400ms was paid on *every* dismissal.
|
||||
*
|
||||
* A value set too low degrades to a cosmetic snap — the padding jumps to its final position
|
||||
* instead of animating — never to wrong padding, since the target is always the truthful reading.
|
||||
*/
|
||||
const val IME_STRAND_GRACE_MS = 120L
|
||||
|
||||
/**
|
||||
* The animated IME inset ([WindowInsets.ime]), corrected for a Compose insets listener that has
|
||||
* stopped updating.
|
||||
*
|
||||
* Compose keeps one `InsetsListener` per window in `WindowInsetsHolder`. It sets an internal
|
||||
* `runningAnimation` flag in `onPrepare` and clears it only in `onEnd` (plus an `onApplyWindowInsets`
|
||||
* fallback that is gated to API 30). While that flag is set, `onApplyWindowInsets` deliberately
|
||||
* skips `update(insets)` and waits for `onProgress` to drive the value instead. So an IME animation
|
||||
* that is prepared and then cancelled without ever delivering `onEnd` — which the back gesture can
|
||||
* do, because the predictive-back window animation races the IME's own close animation — leaves the
|
||||
* flag set forever. Every `WindowInsets` in the window then freezes at its last animated value, and
|
||||
* `imePadding()` holds a keyboard-sized gap open under a keyboard that is already gone.
|
||||
*
|
||||
* Nothing recovers that on its own: the listener is only reset when `WindowInsetsHolder`'s access
|
||||
* count goes 0 -> 1, and this app reads `WindowInsets.ime` app-wide continuously, so the count never
|
||||
* reaches zero while the activity lives. That is why the symptom is sticky — once it starts, only
|
||||
* recreating the activity clears it.
|
||||
*
|
||||
* The escape hatch is that `onApplyWindowInsets` updates
|
||||
* [imeAnimationTarget][WindowInsets.Companion.imeAnimationTarget] *before* it consults that flag, so
|
||||
* the target keeps tracking reality even while the animated value is frozen. This class watches the
|
||||
* two: when they disagree and stop moving, the animated value is stale and the target is the truth.
|
||||
*
|
||||
* That covers the freeze in both directions — a keyboard-height gap left behind after the keyboard
|
||||
* is gone, and missing padding under a keyboard that has come back — since the target is correct
|
||||
* either way.
|
||||
*/
|
||||
@Stable
|
||||
class SafeImeInsets internal constructor(
|
||||
private val animated: WindowInsets,
|
||||
private val target: WindowInsets,
|
||||
) : WindowInsets {
|
||||
/**
|
||||
* `true` while [animated] is known to be frozen at a value the window no longer reports. Set by
|
||||
* the watchdog in [rememberSafeImeInsets]; read from the getters below, so flipping it re-runs
|
||||
* the layout that depends on this inset.
|
||||
*/
|
||||
var isStranded by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
private fun source() = if (isStranded) target else animated
|
||||
|
||||
override fun getLeft(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
) = source().getLeft(density, layoutDirection)
|
||||
|
||||
override fun getTop(density: Density) = source().getTop(density)
|
||||
|
||||
override fun getRight(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
) = source().getRight(density, layoutDirection)
|
||||
|
||||
override fun getBottom(density: Density) = source().getBottom(density)
|
||||
|
||||
/** The pair the watchdog decides on. Taken here so every watcher reads the same two sources. */
|
||||
internal fun sample(density: Density) = ImeInsetSample(animated.getBottom(density), target.getBottom(density))
|
||||
|
||||
internal companion object {
|
||||
/**
|
||||
* One instance per window, keyed the same way Compose keys `WindowInsetsHolder` itself.
|
||||
*
|
||||
* [isStranded] describes the window's insets listener, not any one layout, so every call
|
||||
* site in a window has to read the same flag. Two instances would each run their own
|
||||
* [IME_STRAND_GRACE_MS] timer and could hold different answers across a frame — which
|
||||
* matters where one reading pads a layout and another is subtracted from it, as in
|
||||
* `DisappearingScaffold`. A `CompositionLocal` cannot do this job: a `Dialog` composes
|
||||
* against its own window and must get its own instance, which falls out of keying on the
|
||||
* view for free.
|
||||
*
|
||||
* Weak keys, and nothing here holds the view: the entry dies with the window.
|
||||
*/
|
||||
private val perView = WeakHashMap<View, SafeImeInsets>()
|
||||
|
||||
fun forView(
|
||||
view: View,
|
||||
animated: WindowInsets,
|
||||
target: WindowInsets,
|
||||
): SafeImeInsets = synchronized(perView) { perView.getOrPut(view) { SafeImeInsets(animated, target) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** One reading of the animated IME inset next to the target it is animating towards. */
|
||||
internal data class ImeInsetSample(
|
||||
val animated: Int,
|
||||
val target: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reports whether the animated IME inset has gone stale.
|
||||
*
|
||||
* The two readings differ for the whole length of every legitimate IME animation, so the value
|
||||
* alone can't tell "animating" from "frozen" — only the clock can. A running animation emits a new
|
||||
* sample every frame and [collectLatest] restarts the wait; a frozen one emits nothing, so the wait
|
||||
* is allowed to finish and the inset is declared stranded.
|
||||
*
|
||||
* Extracted from [rememberSafeImeInsets] so the timing can be tested on virtual time.
|
||||
*/
|
||||
internal suspend fun watchForStrandedIme(
|
||||
samples: Flow<ImeInsetSample>,
|
||||
graceMillis: Long = IME_STRAND_GRACE_MS,
|
||||
onStranded: (Boolean) -> Unit,
|
||||
): Unit =
|
||||
samples.collectLatest { sample ->
|
||||
if (sample.animated == sample.target) {
|
||||
onStranded(false)
|
||||
} else {
|
||||
delay(graceMillis)
|
||||
onStranded(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The IME inset to lay out against, in place of [WindowInsets.ime]. See [SafeImeInsets] for why the
|
||||
* raw one can't be trusted for the lifetime of the activity.
|
||||
*
|
||||
* One instance per window, shared by every call site in it — see [SafeImeInsets.forView]. Each call
|
||||
* site still parks its own watchdog, which is redundant but harmless: they all decide from the same
|
||||
* two sources and write the same shared flag, so they cannot disagree. Collapsing them to a single
|
||||
* watchdog would need either a scope outliving every call site (which would strongly hold the view
|
||||
* and defeat the weak cache) or a hand-off when the owning call site leaves the composition; both
|
||||
* cost more than the coroutine they save, since a watchdog is idle whenever the two readings agree
|
||||
* — that is, always, apart from the length of an IME animation.
|
||||
*
|
||||
* `imeAnimationTarget` is still marked experimental, but it is the whole point of this file: it is
|
||||
* the only IME reading Compose keeps current while its listener is wedged, because
|
||||
* `onApplyWindowInsets` publishes it before it consults the flag that gates everything else.
|
||||
*
|
||||
* The underlying defect is upstream, not ours: a cancelled IME animation never delivers `onEnd`,
|
||||
* so `InsetsListener.runningAnimation` stays set and `composeInsets.update()` is never called
|
||||
* again — `WindowInsets.ime` is dead for the life of the window. Filed as **b/552500419**;
|
||||
* a regression in foundation-layout 1.4.0, still present in 1.12.0 and 1.13.0-alpha01. Compose's
|
||||
* own self-heal is scoped to `SDK_INT == R`, and its other reset (`WindowInsetsHolder.resetState()`)
|
||||
* only runs when the holder's accessCount goes 0 -> 1, which never happens in an app whose shell
|
||||
* always reads insets. Retire this file once that bug is fixed — `ComposeImeInsetWedgeTest`
|
||||
* (androidTest) is the canary that says when.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun rememberSafeImeInsets(): SafeImeInsets {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
val animated = WindowInsets.ime
|
||||
val target = WindowInsets.imeAnimationTarget
|
||||
|
||||
val insets = remember(view, animated, target) { SafeImeInsets.forView(view, animated, target) }
|
||||
|
||||
LaunchedEffect(insets, density) {
|
||||
watchForStrandedIme(snapshotFlow { insets.sample(density) }) { insets.isStranded = it }
|
||||
}
|
||||
|
||||
return insets
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `Modifier.imePadding()` that survives a stranded IME inset.
|
||||
*
|
||||
* Prefer this everywhere in the app; `imePadding()` reads the raw animated inset and will hold a
|
||||
* keyboard-sized gap open for the rest of the activity's life once Compose's insets listener wedges.
|
||||
* Consumption semantics are identical — this is `windowInsetsPadding` over the same inset.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.imePaddingSafe(): Modifier = windowInsetsPadding(rememberSafeImeInsets())
|
||||
+12
-6
@@ -25,8 +25,6 @@ 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.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
@@ -62,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberDisappearingBarState
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
|
||||
import com.vitorpamplona.amethyst.ui.insets.SafeImeInsets
|
||||
import com.vitorpamplona.amethyst.ui.insets.rememberSafeImeInsets
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -125,11 +125,16 @@ fun DisappearingScaffold(
|
||||
// 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).
|
||||
// Hoisted above the branch: imePaddingSafe() inside both arms would put the call in two
|
||||
// different composition groups, so toggling canHideBars (a window-size-class change) would
|
||||
// dispose and rebuild it. Resolved once here, and handed to ScaffoldLayout below so the value
|
||||
// this pads with is the same object the nav-bar subtraction reads.
|
||||
val imeInsets = rememberSafeImeInsets()
|
||||
val baseModifier =
|
||||
if (canHideBars) {
|
||||
Modifier.imePadding().nestedScroll(connection)
|
||||
Modifier.windowInsetsPadding(imeInsets).nestedScroll(connection)
|
||||
} else {
|
||||
Modifier.imePadding()
|
||||
Modifier.windowInsetsPadding(imeInsets)
|
||||
}
|
||||
val rootModifier =
|
||||
baseModifier
|
||||
@@ -147,6 +152,7 @@ fun DisappearingScaffold(
|
||||
topBar = topBar,
|
||||
bottomBar = bottomBar,
|
||||
floatingButton = floatingButton,
|
||||
imeInsets = imeInsets,
|
||||
mainContent = mainContent,
|
||||
)
|
||||
}
|
||||
@@ -158,10 +164,10 @@ private fun ScaffoldLayout(
|
||||
topBar: (@Composable () -> Unit)?,
|
||||
bottomBar: (@Composable () -> Unit)?,
|
||||
floatingButton: (@Composable () -> Unit)?,
|
||||
imeInsets: SafeImeInsets,
|
||||
mainContent: @Composable (padding: PaddingValues) -> Unit,
|
||||
) {
|
||||
val navBarInsets = WindowInsets.navigationBars
|
||||
val imeInsets = WindowInsets.ime
|
||||
SubcomposeLayout { constraints ->
|
||||
val layoutWidth = constraints.maxWidth
|
||||
val layoutHeight = constraints.maxHeight
|
||||
@@ -200,7 +206,7 @@ private fun ScaffoldLayout(
|
||||
// hides itself on canPop entries, or while the keyboard is up), reserve the
|
||||
// system-nav-bar inset so the FAB and content stay clear of the navigation bar instead
|
||||
// of sliding under it. Subtract the IME inset: the root imePadding has already lifted the
|
||||
// whole scaffold above the keyboard, and WindowInsets.ime spans the nav-bar band, so
|
||||
// whole scaffold above the keyboard, and the IME inset spans the nav-bar band, so
|
||||
// reserving the full nav bar on top of that would double-count and strand a gap above the
|
||||
// keyboard (the `bottomBar == null` branch gets this for free via navigationBarsPadding,
|
||||
// which excludes the consumed IME inset). The `bottomBar == null` case is on rootModifier.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user