Merge remote-tracking branch 'origin/main' into claude/kind-keller-itzy63

# Conflicts:
#	quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt
This commit is contained in:
Claude
2026-09-23 20:27:58 +00:00
1338 changed files with 15812 additions and 7387 deletions
+12 -4
View File
@@ -58,10 +58,10 @@ amethyst/ # Android app module
│ │ │ │ ├── MainActivity.kt # Entry point
│ │ │ │ ├── navigation/
│ │ │ │ │ ├── AppNavigation.kt # NavHost
│ │ │ │ │ ├── routes/Routes.kt # @Serializable routes
│ │ │ │ │ ├── routes/RouteNavController.kt # NavHost-bound route helpers
│ │ │ │ │ └── bottombars/AppBottomBar.kt
│ │ │ │ ├── screen/ # 80+ screens
│ │ │ │ └── theme/Theme.kt # Material3 theme
│ │ │ │ └── theme/Theme.kt # AmethystTheme (accent, prefs, window insets)
│ │ │ └── Amethyst.kt # Application class
│ │ └── AndroidManifest.xml # Permissions, intent filters
│ └── androidMain/ # KMP Android source set
@@ -69,6 +69,12 @@ amethyst/ # Android app module
└── build.gradle # Android config
```
The `@Serializable` `Route` catalog is headless and lives in
`commons/.../commons/model/navigation/Routes.kt`; `INav`/`EmptyNav` and the top bars are
in `commonsUI/.../commons/ui/navigation/`. The palettes, `ColorScheme.*` tokens, sizes and
typography are in `commonsUI/.../commons/ui/theme/` (markdown styles in its `jvmAndroid`).
Add a route to the commons `Routes.kt`; wire its destination in the app's `AppNavigation.kt`.
## 1. Type-Safe Navigation
### Pattern: @Serializable Routes
@@ -953,9 +959,11 @@ fun testPermissionRequest() {
**Key Android Files:**
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt` (route catalog)
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt` (`AmethystTheme`)
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt` (palettes + `ColorScheme.*` tokens)
- `amethyst/src/main/AndroidManifest.xml`
- `amethyst/build.gradle`
@@ -608,7 +608,9 @@ fun testNavigationToProfile() {
## File Locations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt` (route catalog)
- `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteNavController.kt` (NavHost helpers)
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
@@ -0,0 +1,154 @@
# Replaceable-event backup conflicts
## Problem
`AccountSettings` keeps an on-disk backup of ~26 of the account's replaceable events
(profile, follow list, mute list, relay lists, NIP-51 lists, wallet events, app settings).
Each `*State` collector copies whatever is newest in `LocalCache` into that backup. When
another client rewrites one of these events without reading the previous version (a fresh
follow list, a mute list without the private items, a profile missing fields), the newer
event wins on relays, lands in `LocalCache`, and the backup is overwritten with it: the
only surviving copy of the user's data is gone.
## Design
1. **Know what we signed.** `LocallySignedEvents` (commons, `model/backups/`) records the
ids of replaceable/addressable events passing through `justConsumeMyOwnEvent`, the
choke point for every local publish and for the backup restore at startup. It is a
bounded LRU (500 ids): only the latest versions of each list matter.
2. **Events diff themselves.** Every backed-up event implements
`DiffableEvent<D>` (quartz, `nip01Core/diff/`): `diffFrom(older)` returns its own diff
class built from its own parsed objects, e.g. `ContactListDiff(follows:
ListDiff<ContactTag>)`, `MuteListDiff(publicMutes: ListDiff<MuteTag>, privateItems)`,
`AdvertisedRelayListDiff(relays: ListDiff<AdvertisedRelayInfo>)`, `NutzapInfoDiff(mints:
ListDiff<NutzapMintTag>, relays, p2pkPubkey: ValueChange<HexKey>)`, `MetadataDiff` with
one `ValueChange` per `UserMetadata` field plus NIP-39 claims and unmodeled JSON fields.
The relay-tag lists (DM, key package, search, indexer, feeds, blocked, trusted, private
outbox) share `RelayListDiff`; encrypted-only events (Cashu wallet, Concord list, NIP-78
data) diff their content as a whole.
Tags an event's parser doesn't read never reach its diff, so bookkeeping other clients
rewrite on every save (`client`, NIP-13 `nonce`, NIP-40 `expiration`) is ignored by
construction; `EventDiffTest.clientPowAndExpirationTagsAreIgnored` pins it.
`nip01Core/diff/` only holds the containers: `EventDiff` (`removesData()`),
`ListDiff<T>` (items matched by an identity key; a matched pair with different details
is a change, not a removal plus an addition), `ValueChange<T>`, and `ContentChange` for
NIP-44 private items, which can't be compared item by item without decrypting.
3. **Only question lossy external rewrites.** `BackupConflictGuard` (commons,
`model/backups/`, tested in `BackupConflictGuardTest`) guards every `update*` backup
method of `AccountSettings`, which owns one as `backupGuard`. A newer version that was not signed here goes through
`ReplaceableBackupDiff.detectLoss` (commons), which asks the event for its diff and
keeps it only when `removesData()`.
If nothing was dropped, the other app evidently built on the previous version, and the
backup is updated silently as before.
Wiped versions (no tags, empty app data, a mints-less nutzap info) reach the guard too,
so wiping everything is questioned like any other loss. An empty version only becomes
the backup when this app signed it or the user kept it. The guard runs under a lock
shared with the resolutions, and a version older than an open conflict's incoming can
never replace or clear it (e.g. the retry of an accept that a newer change overtook).
4. **Freeze and ask.** On a loss, the backup keeps the saved version and a
`ReplaceableBackupConflict` is published on `BackupConflictGuard.conflicts`.
Home shows a card per open conflict as the first item of each tab's feed
(`BackupConflictCards`), so they scroll away with the posts instead of covering them;
an empty, loading or failed feed shows them above its placeholder: "You changed your mute list in another app" with a
"12 removed · 3 added" summary. Cards can't be dismissed; they stay until the user
decides. The most recent conflict gets a lead card with an event-specific headline
("You unblocked 3 relays", "You unfollowed 12 people" with its bar); the others collapse
into slim pills. List headlines lead with what was removed and add
what was added to the same list, worded per list ("You unfollowed 12 people and
followed 40", "You left 2 groups and joined 1", "You unblocked 3 relays and blocked 1").
All copy assumes the change was on purpose: the user did it in another client, and
Amethyst is only confirming before its backup moves on. So it says what "you" did, in
the past tense ("You left 1 public chat", not "You'd leave"), and never calls it a loss.
Tapping one opens `Route.BackupConflictReview(slot)`
(`BackupConflictReviewScreen`): a `LazyColumn` of every entry, so lists with hundreds of
items scroll lazily. Entries are typed (`ReviewItem`, built per diff class in
`BackupConflictPresentation.kt`) and rendered with the app's loaders, which subscribe to
relays and recompose when data arrives: people (`LoadUser` + `UsernameDisplay`, tap →
profile), muted threads (`LoadNote` + `NoteCompose`), communities and feeds
(`LoadAddressableNote` + `NoteCompose`), public chats (`observeChannel`, tap → chat),
ephemeral rooms (tap → room) and relays (tap → relay info). Leaving the screen without
deciding keeps the card and the frozen backup.
The list is laid out per event (`BackupConflictEventViews.kt`, `eventDiffItems`), each
leading with one picture of the change (`BackupConflictVisuals.kt`; red lost, green
gained, amber changed, all from the theme): the follow list shows saved → new counts, a
kept/dropped/new split bar, Dropped/New/Edited tabs and a searchable grid of faces; the
mute list a shield with the unmuted count, per-kind tiles, a face pile, struck-through
words and the private-items card; the profile both versions side by side as mini
cards, then inline field diffs with images as thumbnails; NIP-65 outbox and inbox
lanes; nutzap info a key swap
drawn as color fingerprints with a warning, then mints; groups a tile per group, faded
when you left it. The other lists (`BackupConflictListViews.kt`) get their own too:
each relay list as per-relay rows tagged by what the list is for (DM, key-package,
search, indexer, relay-feed, private-outbox and trusted relays; no "N of M" summary card,
since the change is the tagged rows, not a reach number); blocked relays
as an "N unblocked" warning with allowed / newly blocked / still blocked), public
chats, communities, favorite feeds and ephemeral rooms as tiles loaded from relays and
faded when you left them, hashtags and places as a pill cloud (places as "City · #geohash" through `LoadCityName`, each pill opening its feed), trust providers as a
per-service before/after table, payment targets and BOLT12 offers as cards, and the
encrypted-only Cashu wallet and Concord list as one explained panel. Only NIP-78 app
settings still uses the generic removed / added / changed sections.
Each card has an Accept button that keeps the new version right from Home (the change
was made on purpose; reverting needs the review screen, a tap on the card away). The
review screen is titled with the event and what happened to it ("Community list
updated", "Mute list cleared").
Buttons are worded per event ("Keep 120" / "Restore 523", "Re-mute 37", "Rejoin 3").
Keeping the new version is the default (the filled button, on the right); restoring
always reverts another app's change, so it is an outlined red button on every screen.
The screen offers:
- **Restore saved version** — `Account.restoreBackupOver` re-signs the saved kind, tags
and content (NIP-44 self-encrypted items stay valid) with
`created_at = max(now, incoming + 1)` (capped at 15 minutes ahead; a far-future
external version is answered at "now"), dropping the old `client` tag (so the signer
adds the current one), the `nonce` (its PoW was for the old id) and an already-past
`expiration` (`BackupRestore.tagsToResign`), and publishes it through
`EventBroadcaster.sendRestoredVersion`: where a normal save of that kind goes, and for
the user's own relay lists (NIP-65, DM, key package) and nutzap info also to every
relay the restored list names, since the lossy version may have dropped them from the
outbox set; profiles, like their normal saves, go everywhere.
- **Use new version** — `Account.acceptExternalVersion` whitelists that id and re-runs
the original update, so the backup moves forward.
Both resolutions first *claim* the conflict: they act only if it is still the open
conflict of its slot, so a stale screen, a double tap, or a conflict dropped because the
wallet/nutzap info was deleted does nothing. While a restore is signing, its incoming
version can't re-raise the conflict; a failed restore reopens it. The same incoming
version re-emitted by the cache is ignored without re-diffing.
Public items removed while the private section went from empty to filled are treated as
made private, not lost (`ContentChange.publicRemovalsAreLoss`).
While a conflict is open, edits made in Amethyst are compared against the frozen
saved version too (they are built on top of the external one), so they cannot
silently clear the conflict. They move the conflict's `incoming` forward but keep the
external version as its `cause` (the screen shows that date).
Open conflicts are persisted with the backups (`BackupConflictStorage`, as the saved,
incoming and cause events) and re-seeded on load through `BackupConflictGuard.restore`. The
diff is recomputed, so a version the other app has since fixed stops being a conflict. A
restored conflict has no retry closure until its version arrives again; keeping it rebuilds
the update from the event's type (`AccountSettings.reapplyBackup`), except NIP-78, which
lands on the next delivery.
## Known limits / follow-ups
- Private (encrypted) list items are only compared as "all gone"; a rewrite that drops
*some* private items isn't detected. Decrypting both sides would fix it but needs the
signer (an Amber round-trip), so it's left out of the passive path.
- The ids of locally signed events are in memory only. If the app dies inside the 1s
settings-save debounce right after a local removal, the next launch may ask once about
our own event. Harmless (either answer keeps the data the user chose).
- Edits from the same user on another Amethyst install count as "external" when they
remove items. That's intended: the device can't tell them apart from a careless app.
- Desktop marks its own events too but has no backup store or conflict UI yet.
- Moving public items into a private section that already existed is still questioned: the
private section re-encrypts on every save, so it only reads as "changed", and telling
"made private" from "deleted" would need decrypting it.
- A version dated more than 15 minutes in the future can't be outranked safely, so Restore
refuses it (the conflict reopens) and only keeping it works.
- Read-only (npub) accounts can't re-sign the saved version, so their review offers only
keeping the new one.
+3 -3
View File
@@ -240,6 +240,6 @@
# The enum FIELDS are already pinned by the blanket `-keepclassmembers enum *`
# above; that rule deliberately lets the CLASS be renamed, which is exactly what
# breaks here. Every enum used as a route argument needs its name too.
-keep class com.vitorpamplona.amethyst.ui.navigation.routes.DiscoverTab { *; }
-keep class com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType { *; }
-keep class com.vitorpamplona.amethyst.ui.navigation.routes.GeocacheTab { *; }
-keep class com.vitorpamplona.amethyst.commons.model.navigation.DiscoverTab { *; }
-keep class com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType { *; }
-keep class com.vitorpamplona.amethyst.commons.model.navigation.GeocacheTab { *; }
@@ -26,6 +26,9 @@ import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
@@ -34,9 +37,6 @@ import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
@@ -67,10 +67,13 @@ import com.vitorpamplona.amethyst.commons.resources.push_server_title
import com.vitorpamplona.amethyst.commons.resources.push_server_uses_app_explainer
import com.vitorpamplona.amethyst.commons.resources.quick_action_dont_show_again_button
import com.vitorpamplona.amethyst.commons.resources.select_push_server
import com.vitorpamplona.amethyst.commons.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -27,9 +27,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
@Composable
fun TranslatableRichTextViewer(
@@ -24765,9 +24765,9 @@ Lcom/vitorpamplona/amethyst/ui/StringResourceCacheKt$StringResSetup$lambda$0$0$$
SPLcom/vitorpamplona/amethyst/ui/StringResourceCacheKt$StringResSetup$lambda$0$0$$inlined$onPauseOrDispose$1;-><init>(Landroidx/lifecycle/compose/LifecycleResumePauseEffectScope;)V
Lcom/vitorpamplona/amethyst/ui/components/WindowUtilsKt;
SPLcom/vitorpamplona/amethyst/ui/components/WindowUtilsKt;->getActivityOrNull(Landroid/content/Context;)Landroidx/activity/ComponentActivity;
Lcom/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBarKt;
SPLcom/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBarKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/navigation/topbars/ShorterTopAppBarKt;->getTopBarSize()F
Lcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;->getTopBarSize()F
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;->NowProvider(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
@@ -25489,14 +25489,14 @@ SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$Key
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$KeyTextFieldKt$$ExternalSyntheticLambda1;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$KeyTextFieldKt$$ExternalSyntheticLambda1;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$KeyTextFieldKt$$ExternalSyntheticLambda1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;->getLambda$-1905508794$amethyst()Lkotlin/jvm/functions/Function3;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt;->lambda__1905508794$lambda$0(Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;-><init>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;->getLambda$-1905508794$amethyst()Lkotlin/jvm/functions/Function3;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt;->lambda__1905508794$lambda$0(Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;-><init>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/ComposableSingletons$SignUpButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt;->$r8$lambda$EB2Pyswic2JGUT6jyNtb4e7r5z8(Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt;->$r8$lambda$raCAC-FTNT20_jVOcW9Lmx1V5zI(Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Lkotlin/Unit;
@@ -25529,12 +25529,12 @@ SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$External
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$ExternalSyntheticLambda9;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$ExternalSyntheticLambda9;-><init>(Landroidx/compose/runtime/MutableState;)V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/KeyTextFieldKt$$ExternalSyntheticLambda9;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt;->LoginButton$lambda$0(ZLandroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt;->LoginButton(ZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt$$ExternalSyntheticLambda0;-><init>(Z)V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;->LoginButton$lambda$0(ZLandroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;->LoginButton(ZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;-><init>(Z)V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;-><init>()V
@@ -25613,26 +25613,28 @@ Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$$Ex
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$$ExternalSyntheticLambda3;-><init>(Lkotlin/jvm/functions/Function2;I)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$OpenURIIfNotLoggedIn$lambda$5$0$$inlined$onDispose$1;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedInKt$OpenURIIfNotLoggedIn$lambda$5$0$$inlined$onDispose$1;-><init>(Landroidx/activity/ComponentActivity;Landroidx/core/util/Consumer;)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/SignUpButtonKt;->SignUpButton(Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/ui/theme/ColorKt;
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;->getPurple200()J
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;->getPurple500()J
SPLcom/vitorpamplona/amethyst/ui/theme/ColorKt;->getTeal200()J
Lcom/vitorpamplona/amethyst/ui/theme/ShapeKt;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getQuoteBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getShapes()Landroidx/compose/material3/Shapes;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize10dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize13dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize20dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize35dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize40dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSize50dp()F
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSmallBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/ui/theme/ShapeKt;->getSmallestBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/SignUpButtonKt;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/SignUpButtonKt;->SignUpButton(Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;->getPurple200()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;->getPurple500()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ColorKt;->getTeal200()J
Lcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getQuoteBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getShapes()Landroidx/compose/material3/Shapes;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize10dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize13dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize20dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize35dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize40dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize50dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallestBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/MarkdownStyleKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->$r8$lambda$7JAdMf4levt4F3mr6nvsppu__xY(Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->$r8$lambda$GsT95FSXMVwriWjqAt3KW-oAPpo(Landroid/view/View;ZLandroidx/compose/material3/ColorScheme;)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;-><clinit>()V
@@ -25645,16 +25647,16 @@ SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme$lambda$9$0(Landro
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme$lambda$9(Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lcom/vitorpamplona/amethyst/model/ThemeType;Lcom/vitorpamplona/amethyst/model/AccentColorType;Lcom/vitorpamplona/amethyst/model/FontFamilyType;Lcom/vitorpamplona/amethyst/model/FontSizeType;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->accentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentPrimary(Lcom/vitorpamplona/amethyst/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentSecondary(Lcom/vitorpamplona/amethyst/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->darkColors(Lcom/vitorpamplona/amethyst/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->getPlaceholderText(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->getTransparentBackground(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->isLight(Landroidx/compose/material3/ColorScheme;)Z
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getPlaceholderText(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getTransparentBackground(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->isLight(Landroidx/compose/material3/ColorScheme;)Z
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->lightColors(Lcom/vitorpamplona/amethyst/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->onAccent-8_81llA(J)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->onAccentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccent-8_81llA(J)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccentContainer-DxMtmZc(JZ)J
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;-><init>(Landroidx/compose/ui/unit/Density;Landroidx/compose/ui/text/font/FontFamily;Lkotlin/jvm/functions/Function2;)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
@@ -25676,19 +25678,19 @@ SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$MarkDownStyleOnLight$1;-><clinit>
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$MarkDownStyleOnLight$1;-><init>()V
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt$WhenMappings;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt$WhenMappings;-><clinit>()V
Lcom/vitorpamplona/amethyst/ui/theme/TypeKt;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getDefaultHeadingStyle()Lkotlin/jvm/functions/Function2;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getDefaultParagraphSpacing()J
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getFont14SP()J
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getFont17SP()J
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->getTypography()Landroidx/compose/material3/Typography;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt;->withFontFamily(Landroidx/compose/material3/Typography;Landroidx/compose/ui/text/font/FontFamily;)Landroidx/compose/material3/Typography;
Lcom/vitorpamplona/amethyst/ui/theme/TypeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt$$ExternalSyntheticLambda0;-><init>()V
Lcom/vitorpamplona/amethyst/ui/theme/TypeKt$WhenMappings;
SPLcom/vitorpamplona/amethyst/ui/theme/TypeKt$WhenMappings;-><clinit>()V
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/MarkdownStyleKt;->getDefaultHeadingStyle()Lkotlin/jvm/functions/Function2;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getDefaultParagraphSpacing()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont14SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont17SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getTypography()Landroidx/compose/material3/Typography;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->withFontFamily(Landroidx/compose/material3/Typography;Landroidx/compose/ui/text/font/FontFamily;)Landroidx/compose/material3/Typography;
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;-><init>()V
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$WhenMappings;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$WhenMappings;-><clinit>()V
Lcom/vitorpamplona/amethyst/ui/tor/ArtiGuardState;
SPLcom/vitorpamplona/amethyst/ui/tor/ArtiGuardState;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/tor/ArtiGuardState;-><init>()V
@@ -30,6 +30,7 @@ import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
@@ -49,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
@@ -59,7 +61,6 @@ import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
@@ -458,13 +459,14 @@ class AppModules(
scope = applicationIOScope,
dns = surgeDns,
// Transparently rewrites sha256-keyed HTTP requests to the local
// Blossom cache when the master toggle is on, the profile-pictures-only
// restriction is off, and the probe sees 127.0.0.1:24242 as available.
shouldBridgeBlossomCache = {
// Blossom cache when the master toggle is on, the probe sees
// 127.0.0.1:24242 as available, and either the profile-pictures-only
// restriction is off or the request is a profile picture.
shouldBridgeBlossomCache = { profilePicture ->
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
master && (profilePicture || !profileOnly) && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
@@ -477,6 +479,7 @@ class AppModules(
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
onAuthRequired = blossomReadAuthTokens::warm,
),
onLocalBlossomCacheUnreachable = { localBlossomCacheProbe.markUnavailable() },
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -1035,14 +1038,16 @@ class AppModules(
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
// Same gate as the interceptor: the profile-pictures-only restriction keeps note
// media (including native `blossom:` URIs) off the local cache.
useLocalBlossomCache = {
sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value ?: false
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly
},
localCacheProbe = localBlossomCacheProbe,
scope = applicationIOScope,
)
}
@@ -1148,7 +1153,8 @@ class AppModules(
blossomServerResolver = { blossomResolver },
// Through the role builder (not raw getHttpClient) so Coil's image
// traffic carries the "image" ledger tag. Same Tor decision inside.
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
// Marked as media so the local Blossom cache may serve these downloads.
callFactory = { LocalBlossomMediaCallFactory(roleBasedHttpClientBuilder.okHttpClientForImage(it)) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
readAuth = blossomReadAuthTokens,
@@ -1341,8 +1347,7 @@ class AppModules(
state.account.settings.localBlossomCacheProfilePicturesOnly
.drop(1),
).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
blossomResolver.clearCaches()
localBlossomCacheProbe.invalidate()
// Re-probe immediately so enabling the feature activates it
// this session. Otherwise `available` only advances when a
@@ -1358,8 +1363,14 @@ class AppModules(
}
applicationIOScope.launch {
localBlossomCacheProbe.available.drop(1).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
blossomResolver.clearCaches()
}
}
// A resolution to the local cache made while a media type was not Tor-routed must not
// survive the user switching it to Tor: the cache would keep fetching it outside Tor.
applicationIOScope.launch {
torPrefs.value.propertyWatchFlow.drop(1).collect {
blossomResolver.clearCaches()
}
}
// Warm the local-cache probe so the very first image load doesn't pay
@@ -1367,6 +1378,22 @@ class AppModules(
applicationIOScope.launch {
localBlossomCacheProbe.isAvailable()
}
// Re-checks the local cache about once a minute while the feature is on, so the bridge
// comes back when the cache app is restarted and turns off when it is closed, even while
// no `blossom:` URI is being resolved. A loopback HEAD never wakes the radio.
applicationIOScope.launch {
while (true) {
delay(LOCAL_BLOSSOM_CACHE_RECHECK_MS)
if (sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value == true
) {
localBlossomCacheProbe.isAvailable()
}
}
}
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
@@ -1505,5 +1532,8 @@ class AppModules(
* check and burn CPU on a heap that has nothing left to give.
*/
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
/** How often the local Blossom cache is re-probed while the feature is enabled. */
private const val LOCAL_BLOSSOM_CACHE_RECHECK_MS = 60_000L
}
}
@@ -29,6 +29,8 @@ import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
@@ -36,9 +38,9 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
@@ -158,6 +160,7 @@ private object PrefKeys {
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
const val OPEN_BACKUP_CONFLICTS = "openBackupConflicts"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
@@ -581,6 +584,16 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
// The undecided conflicts themselves, not just the backups they hold back.
// Without these the card vanishes on the next launch and the user never
// answers the question the backup is still waiting on.
val openConflicts = settings.openBackupConflicts()
if (openConflicts.isEmpty()) {
remove(PrefKeys.OPEN_BACKUP_CONFLICTS)
} else {
putString(PrefKeys.OPEN_BACKUP_CONFLICTS, BackupConflictStorage.encode(openConflicts))
}
putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata)
putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList)
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
@@ -807,6 +820,7 @@ object LocalPreferences {
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null)
val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null)
val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null)
val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null)
@@ -959,6 +973,7 @@ object LocalPreferences {
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
cashuCounters = CashuPreferences.forAccount(keyPair.pubKey.toNpub()),
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServerResolved,
@@ -1062,7 +1077,7 @@ object LocalPreferences {
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
)
).also { it.restoreBackupConflicts(BackupConflictStorage.decode(openBackupConflictsStr)) }
}
}
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
@@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable_desc
import com.vitorpamplona.amethyst.commons.resources.nip46_connect_requests_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class SignerConnectActivity : ComponentActivity() {
@@ -95,12 +95,12 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_no
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.model.backups.BackupRestore
import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
@@ -52,6 +54,8 @@ import com.vitorpamplona.amethyst.commons.model.edits.PrivateStorageRelayListDec
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -66,16 +70,27 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nipBCOnchainZaps.OnchainWalletState
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.FeedDecryptionCaches
@@ -103,9 +118,6 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.AccountMarmotActions
import com.vitorpamplona.amethyst.model.AccountRelayGroupActions
import com.vitorpamplona.amethyst.model.EventBroadcaster
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
@@ -132,38 +144,27 @@ import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedListState
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
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.nipBCOnchainZaps.OnchainWalletState
import com.vitorpamplona.amethyst.model.serverList.AssumedRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
@@ -175,8 +176,6 @@ import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.marmotquic.QuicAgentTextStreamTransport
import com.vitorpamplona.quartz.buzz.threading.buzzThread
@@ -859,7 +858,7 @@ class Account(
* relay subscription only runs while at least one opener is active.
*/
val cashuMintDirectoryState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuMintDirectoryState(
com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuMintDirectoryState(
cache = cache,
scope = scope,
assembler = cashuMintDirectoryFilterAssembler(),
@@ -1457,6 +1456,9 @@ class Account(
// wraps still register with the delivery-ticks tracker at publish time.
// Null for restart-restored jobs, whose rumor id wasn't persisted.
displayedNoteId: HexKey? = null,
// Runs once the mined wraps are handed to the relays (e.g. to drop the
// composer's draft). Not persisted: a restart-restored job skips it.
onPublished: suspend () -> Unit = {},
): Boolean {
val queue = powQueue() ?: return false
if (seals.isEmpty()) return true
@@ -1491,7 +1493,10 @@ class Account(
}
seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) }
},
publish = { wraps -> broadcastPrivately(wraps, displayedNoteId) },
publish = { wraps ->
broadcastPrivately(wraps, displayedNoteId)
onPublished()
},
)
return true
}
@@ -1917,6 +1922,40 @@ class Account(
fun sendLiterallyEverywhere(event: Event) = broadcaster.sendLiterallyEverywhere(event)
/**
* Resolves a [ReplaceableBackupConflict] in favor of this device: re-signs the saved
* version (same kind, tags and content, so NIP-44 private items stay readable, minus the
* old client, proof-of-work and past expiration tags; see [BackupRestore.tagsToResign])
* with a timestamp newer than the current one, and publishes it so it replaces that version.
*
* Does nothing if the conflict is stale (already resolved, replaced by a newer one, or
* dropped because the event was deleted), which also makes a double tap harmless.
*/
suspend fun restoreBackupOver(conflict: ReplaceableBackupConflict) {
val token = settings.backupGuard.startRestoring(conflict) ?: return
var restored = false
try {
val saved = conflict.saved
val now = TimeUtils.now()
// Newer than the version it replaces, but never far in the future: relays reject
// those, and later edits signed at "now" would lose to it. A far-future external
// version can't be outranked safely, and a restore signed at "now" would lose to it
// everywhere, so it isn't sent: the conflict reopens and only keeping it works.
val createdAt = (conflict.incoming.createdAt + 1).coerceAtLeast(now)
if (createdAt > now + maxRestoreFutureSeconds) return
val resigned = signer.sign<Event>(createdAt, saved.kind, BackupRestore.tagsToResign(saved, now), saved.content)
broadcaster.sendRestoredVersion(resigned)
restored = true
} finally {
settings.backupGuard.finishRestoring(conflict, token, restored)
}
}
private val maxRestoreFutureSeconds = 15 * 60L
/** Resolves a [ReplaceableBackupConflict] in favor of the current version. No-op when stale. */
fun acceptExternalVersion(conflict: ReplaceableBackupConflict) = settings.backupGuard.keepIncoming(conflict)
suspend fun <T : Event> signAndSendPrivately(
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
@@ -2939,13 +2978,24 @@ class Account(
broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer))
}
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) = sendNip17PrivateMessage(template) {}
/**
* [onSent] runs once the wraps are handed to the relays — right away when
* the message is not mined, after the nonce search when it is — so the
* composer can keep its draft for as long as the message only exists in
* the mining queue.
*/
suspend fun sendNip17PrivateMessage(
template: EventTemplate<ChatMessageEvent>,
onSent: suspend () -> Unit,
) {
val powDifficulty = powDifficultyFor(GiftWrapEvent.KIND)
if (powDifficulty != null) {
// See sendNip17EncryptedFile: sign inline, queue only wrap mining.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id)) {
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id, onPublished = onSent)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
@@ -2954,6 +3004,7 @@ class Account(
}
broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer))
onSent()
}
/**
@@ -2965,10 +3016,13 @@ class Account(
*
* [powOverrideDifficulty] is the composer chip's per-post override:
* null follows the account's gift-wrap setting, 0 disables mining.
* [onSent] runs once the wraps are handed to the relays (after mining,
* when mined).
*/
suspend fun sendPrivateNote(
template: EventTemplate<TextNoteEvent>,
powOverrideDifficulty: Int? = null,
onSent: suspend () -> Unit = {},
) {
if (!isWriteable()) return
@@ -2978,10 +3032,11 @@ class Account(
val senderNote = signer.sign(template)
val recipients = senderNote.taggedUserIds().plus(signer.pubKey).toSet()
val seals = NIP17Factory().createSeals(senderNote, recipients, signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id)) return
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id, onPublished = onSent)) return
}
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
onSent()
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
@@ -22,14 +22,23 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.cashu.UnavailableCashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.backups.BackupConflictGuard
import com.vitorpamplona.amethyst.commons.model.backups.backupSlotOf
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mergeMutedPublicChats
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
@@ -39,25 +48,18 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
@@ -236,6 +238,14 @@ class AccountSettings(
* keysets is fine because the derivation includes the keyset id.
*/
var cashuKeysetCounters: MutableMap<String, Long> = mutableMapOf(),
/**
* Durable NUT-13 counter store for this account, supplied by the host.
*
* Defaulted to the failing stand-in rather than an in-memory map: the preview
* and test factories below never mint, and a host that does mint must wire a
* real store or hear about it on the first reservation.
*/
val cashuCounters: CashuKeysetCounterStore = UnavailableCashuKeysetCounterStore,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
@@ -769,10 +779,6 @@ class AccountSettings(
if (changed) saveAccountSettings()
}
fun changeDefaultHomeFollowList(name: FeedDefinition) {
changeDefaultHomeFollowList(name.code)
}
fun changeDefaultHomeFollowList(name: TopFilter) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
@@ -780,10 +786,6 @@ class AccountSettings(
}
}
fun changeDefaultStoriesFollowList(name: FeedDefinition) {
changeDefaultStoriesFollowList(name.code)
}
fun changeDefaultStoriesFollowList(name: TopFilter) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
@@ -791,10 +793,6 @@ class AccountSettings(
}
}
fun changeDefaultNotificationFollowList(name: FeedDefinition) {
changeDefaultNotificationFollowList(name.code)
}
fun changeDefaultNotificationFollowList(name: TopFilter) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
@@ -802,10 +800,6 @@ class AccountSettings(
}
}
fun changeDefaultDiscoveryFollowList(name: FeedDefinition) {
changeDefaultDiscoveryFollowList(name.code)
}
fun changeDefaultDiscoveryFollowList(name: TopFilter) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
@@ -813,10 +807,6 @@ class AccountSettings(
}
}
fun changeDefaultPollsFollowList(name: FeedDefinition) {
changeDefaultPollsFollowList(name.code)
}
fun changeDefaultPollsFollowList(name: TopFilter) {
if (defaultPollsFollowList.value != name) {
defaultPollsFollowList.tryEmit(name)
@@ -824,10 +814,6 @@ class AccountSettings(
}
}
fun changeDefaultCommunitiesFollowList(name: FeedDefinition) {
changeDefaultCommunitiesFollowList(name.code)
}
fun changeDefaultCommunitiesFollowList(name: TopFilter) {
if (defaultCommunitiesFollowList.value != name) {
defaultCommunitiesFollowList.tryEmit(name)
@@ -835,10 +821,6 @@ class AccountSettings(
}
}
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
changeDefaultPicturesFollowList(name.code)
}
fun changeDefaultPicturesFollowList(name: TopFilter) {
if (defaultPicturesFollowList.value != name) {
defaultPicturesFollowList.tryEmit(name)
@@ -846,10 +828,6 @@ class AccountSettings(
}
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: FeedDefinition) {
changeDefaultRelayGroupsDiscoveryFollowList(name.code)
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: TopFilter) {
if (defaultRelayGroupsDiscoveryFollowList.value != name) {
defaultRelayGroupsDiscoveryFollowList.tryEmit(name)
@@ -857,10 +835,6 @@ class AccountSettings(
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
fun changeDefaultNappletsFollowList(name: TopFilter) {
if (defaultNappletsFollowList.value != name) {
defaultNappletsFollowList.tryEmit(name)
@@ -868,10 +842,6 @@ class AccountSettings(
}
}
fun changeDefaultNsitesFollowList(name: FeedDefinition) {
changeDefaultNsitesFollowList(name.code)
}
fun changeDefaultNsitesFollowList(name: TopFilter) {
if (defaultNsitesFollowList.value != name) {
defaultNsitesFollowList.tryEmit(name)
@@ -879,10 +849,6 @@ class AccountSettings(
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
fun changeDefaultWorkoutsFollowList(name: TopFilter) {
if (defaultWorkoutsFollowList.value != name) {
defaultWorkoutsFollowList.tryEmit(name)
@@ -890,10 +856,6 @@ class AccountSettings(
}
}
fun changeDefaultGitRepositoriesFollowList(name: FeedDefinition) {
changeDefaultGitRepositoriesFollowList(name.code)
}
fun changeDefaultGitRepositoriesFollowList(name: TopFilter) {
if (defaultGitRepositoriesFollowList.value != name) {
defaultGitRepositoriesFollowList.tryEmit(name)
@@ -901,10 +863,6 @@ class AccountSettings(
}
}
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
changeDefaultHighlightsFollowList(name.code)
}
fun changeDefaultHighlightsFollowList(name: TopFilter) {
if (defaultHighlightsFollowList.value != name) {
defaultHighlightsFollowList.tryEmit(name)
@@ -912,10 +870,6 @@ class AccountSettings(
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
fun changeDefaultCalendarsFollowList(name: TopFilter) {
if (defaultCalendarsFollowList.value != name) {
defaultCalendarsFollowList.tryEmit(name)
@@ -923,10 +877,6 @@ class AccountSettings(
}
}
fun changeDefaultProductsFollowList(name: FeedDefinition) {
changeDefaultProductsFollowList(name.code)
}
fun changeDefaultProductsFollowList(name: TopFilter) {
if (defaultProductsFollowList.value != name) {
defaultProductsFollowList.tryEmit(name)
@@ -934,10 +884,6 @@ class AccountSettings(
}
}
fun changeDefaultGeocachesFollowList(name: FeedDefinition) {
changeDefaultGeocachesFollowList(name.code)
}
fun changeDefaultGeocachesFollowList(name: TopFilter) {
if (defaultGeocachesFollowList.value != name) {
defaultGeocachesFollowList.tryEmit(name)
@@ -945,10 +891,6 @@ class AccountSettings(
}
}
fun changeDefaultShortsFollowList(name: FeedDefinition) {
changeDefaultShortsFollowList(name.code)
}
fun changeDefaultShortsFollowList(name: TopFilter) {
if (defaultShortsFollowList.value != name) {
defaultShortsFollowList.tryEmit(name)
@@ -956,10 +898,6 @@ class AccountSettings(
}
}
fun changeDefaultPublicChatsFollowList(name: FeedDefinition) {
changeDefaultPublicChatsFollowList(name.code)
}
fun changeDefaultPublicChatsFollowList(name: TopFilter) {
if (defaultPublicChatsFollowList.value != name) {
defaultPublicChatsFollowList.tryEmit(name)
@@ -967,10 +905,6 @@ class AccountSettings(
}
}
fun changeDefaultLiveStreamsFollowList(name: FeedDefinition) {
changeDefaultLiveStreamsFollowList(name.code)
}
fun changeDefaultLiveStreamsFollowList(name: TopFilter) {
if (defaultLiveStreamsFollowList.value != name) {
defaultLiveStreamsFollowList.tryEmit(name)
@@ -978,10 +912,6 @@ class AccountSettings(
}
}
fun changeDefaultNestsFollowList(name: FeedDefinition) {
changeDefaultNestsFollowList(name.code)
}
fun changeDefaultNestsFollowList(name: TopFilter) {
if (defaultNestsFollowList.value != name) {
defaultNestsFollowList.tryEmit(name)
@@ -989,10 +919,6 @@ class AccountSettings(
}
}
fun changeDefaultLongsFollowList(name: FeedDefinition) {
changeDefaultLongsFollowList(name.code)
}
fun changeDefaultLongsFollowList(name: TopFilter) {
if (defaultLongsFollowList.value != name) {
defaultLongsFollowList.tryEmit(name)
@@ -1000,10 +926,6 @@ class AccountSettings(
}
}
fun changeDefaultArticlesFollowList(name: FeedDefinition) {
changeDefaultArticlesFollowList(name.code)
}
fun changeDefaultArticlesFollowList(name: TopFilter) {
if (defaultArticlesFollowList.value != name) {
defaultArticlesFollowList.tryEmit(name)
@@ -1011,10 +933,6 @@ class AccountSettings(
}
}
fun changeDefaultMusicTracksFollowList(name: FeedDefinition) {
changeDefaultMusicTracksFollowList(name.code)
}
fun changeDefaultMusicTracksFollowList(name: TopFilter) {
if (defaultMusicTracksFollowList.value != name) {
defaultMusicTracksFollowList.tryEmit(name)
@@ -1022,10 +940,6 @@ class AccountSettings(
}
}
fun changeDefaultMusicPlaylistsFollowList(name: FeedDefinition) {
changeDefaultMusicPlaylistsFollowList(name.code)
}
fun changeDefaultMusicPlaylistsFollowList(name: TopFilter) {
if (defaultMusicPlaylistsFollowList.value != name) {
defaultMusicPlaylistsFollowList.tryEmit(name)
@@ -1033,10 +947,6 @@ class AccountSettings(
}
}
fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) {
changeDefaultPodcastEpisodesFollowList(name.code)
}
fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) {
if (defaultPodcastEpisodesFollowList.value != name) {
defaultPodcastEpisodesFollowList.tryEmit(name)
@@ -1044,10 +954,6 @@ class AccountSettings(
}
}
fun changeDefaultPodcastsFollowList(name: FeedDefinition) {
changeDefaultPodcastsFollowList(name.code)
}
fun changeDefaultPodcastsFollowList(name: TopFilter) {
if (defaultPodcastsFollowList.value != name) {
defaultPodcastsFollowList.tryEmit(name)
@@ -1055,10 +961,6 @@ class AccountSettings(
}
}
fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) {
changeDefaultSoftwareAppsFollowList(name.code)
}
fun changeDefaultSoftwareAppsFollowList(name: TopFilter) {
if (defaultSoftwareAppsFollowList.value != name) {
defaultSoftwareAppsFollowList.tryEmit(name)
@@ -1066,10 +968,6 @@ class AccountSettings(
}
}
fun changeDefaultBadgesFollowList(name: FeedDefinition) {
changeDefaultBadgesFollowList(name.code)
}
fun changeDefaultBadgesFollowList(name: TopFilter) {
if (defaultBadgesFollowList.value != name) {
defaultBadgesFollowList.tryEmit(name)
@@ -1077,10 +975,6 @@ class AccountSettings(
}
}
fun changeDefaultBrowseEmojiSetsFollowList(name: FeedDefinition) {
changeDefaultBrowseEmojiSetsFollowList(name.code)
}
fun changeDefaultBrowseEmojiSetsFollowList(name: TopFilter) {
if (defaultBrowseEmojiSetsFollowList.value != name) {
defaultBrowseEmojiSetsFollowList.tryEmit(name)
@@ -1088,10 +982,6 @@ class AccountSettings(
}
}
fun changeDefaultFollowPacksFollowList(name: FeedDefinition) {
changeDefaultFollowPacksFollowList(name.code)
}
fun changeDefaultFollowPacksFollowList(name: TopFilter) {
if (defaultFollowPacksFollowList.value != name) {
defaultFollowPacksFollowList.tryEmit(name)
@@ -1099,10 +989,6 @@ class AccountSettings(
}
}
fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) {
changeDefaultAppRecommendationsFollowList(name.code)
}
fun changeDefaultAppRecommendationsFollowList(name: TopFilter) {
if (defaultAppRecommendationsFollowList.value != name) {
defaultAppRecommendationsFollowList.tryEmit(name)
@@ -1158,6 +1044,69 @@ class AccountSettings(
// Backup Lists
// ----
/**
* Holds back external rewrites that dropped data from a backup, see [BackupConflictGuard].
* Home shows a card for each open conflict until the user keeps the new version or
* restores the saved one. Open conflicts are saved with the backups they hold back.
*/
val backupGuard =
BackupConflictGuard(
onChange = { saveAccountSettings() },
reapply = { reapplyBackup(it) },
)
val backupConflicts get() = backupGuard.conflicts
/** Re-seeds the open conflicts read back from storage, see [BackupConflictGuard.restore]. */
fun restoreBackupConflicts(saved: List<Triple<Event, Event, Event>>) = backupGuard.restore(saved)
/** The open conflicts, as the three events storage needs to rebuild each one. */
fun openBackupConflicts(): List<Triple<Event, Event, Event>> = backupGuard.open()
/**
* Re-runs the update that a restored conflict no longer carries.
*
* The guard's retries are closures, so they cannot be written to disk; a conflict read
* back after a restart has none until its version happens to arrive again. Keeping the new
* version must work regardless, and every update is the same shape — hand the event to the
* function that owns its slot — so the slot can be recovered from the event's own type.
*/
private fun reapplyBackup(incoming: Event) {
when (incoming) {
is MetadataEvent -> updateUserMetadata(incoming)
is ContactListEvent -> updateContactListTo(incoming)
is ChatMessageRelayListEvent -> updateDMRelayList(incoming)
is KeyPackageRelayListEvent -> updateKeyPackageRelayList(incoming)
is AdvertisedRelayListEvent -> updateNIP65RelayList(incoming)
is CashuWalletEvent -> updateCashuWallet(incoming)
is NutzapInfoEvent -> updateNutzapInfo(incoming)
is PaymentTargetsEvent -> updateNIPA3PaymentTargets(incoming)
is Bolt12OfferListEvent -> updateBolt12Offers(incoming)
is SearchRelayListEvent -> updateSearchRelayList(incoming)
is IndexerRelayListEvent -> updateIndexRelayList(incoming)
is RelayFeedsListEvent -> updateRelayFeedList(incoming)
is BlockedRelayListEvent -> updateBlockedRelayList(incoming)
is TrustedRelayListEvent -> updateTrustedRelayList(incoming)
is PrivateOutboxRelayListEvent -> updatePrivateHomeRelayList(incoming)
is ChannelListEvent -> updateChannelListTo(incoming)
is GeohashListEvent -> updateGeohashListTo(incoming)
is HashtagListEvent -> updateHashtagListTo(incoming)
is FavoriteAlgoFeedsListEvent -> updateFavoriteAlgoFeedsListTo(incoming)
is CommunityListEvent -> updateCommunityListTo(incoming)
is EphemeralChatListEvent -> updateEphemeralChatListTo(incoming)
is SimpleGroupListEvent -> updateRelayGroupListTo(incoming)
is ConcordCommunityListEvent -> updateConcordListTo(incoming)
is TrustProviderListEvent -> updateTrustProviderListTo(incoming)
is MuteListEvent -> updateMuteList(incoming)
// NIP-78 only raises a conflict when its blob was emptied, and an empty blob is
// stored without merging the settings handed with it, so defaults are safe here.
// A non-empty one needs the decrypted settings only the caller has; it lands when
// the event next arrives, already recorded as accepted.
is AppSpecificDataEvent -> if (incoming.content.isBlank()) updateAppSpecificData(incoming, AccountSyncedSettingsInternal())
else -> Unit
}
}
fun updateLocalRelayServers(servers: Set<String>) {
if (localRelayServers.value != servers) {
localRelayServers.update { servers }
@@ -1176,48 +1125,43 @@ class AccountSettings(
fun updateUserMetadata(newMetadata: MetadataEvent?) {
if (newMetadata == null) return
// Events might be different objects, we have to compare their ids.
if (backupUserMetadata?.id != newMetadata.id) {
if (backupGuard.accept(backupUserMetadata, newMetadata) { updateUserMetadata(newMetadata) }) {
backupUserMetadata = newMetadata
saveAccountSettings()
}
}
fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList == null || newContactList.tags.isEmpty()) return
if (newContactList == null) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList.id) {
if (backupGuard.accept(backupContactList, newContactList, isEmpty = newContactList.tags.isEmpty()) { updateContactListTo(newContactList) }) {
backupContactList = newContactList
saveAccountSettings()
}
}
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
if (newDMRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupDMRelayList?.id != newDMRelayList.id) {
if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) {
backupDMRelayList = newDMRelayList
saveAccountSettings()
}
}
fun updateKeyPackageRelayList(newKeyPackageRelayList: KeyPackageRelayListEvent?) {
if (newKeyPackageRelayList == null || newKeyPackageRelayList.tags.isEmpty()) return
if (newKeyPackageRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupKeyPackageRelayList?.id != newKeyPackageRelayList.id) {
if (backupGuard.accept(backupKeyPackageRelayList, newKeyPackageRelayList, isEmpty = newKeyPackageRelayList.tags.isEmpty()) { updateKeyPackageRelayList(newKeyPackageRelayList) }) {
backupKeyPackageRelayList = newKeyPackageRelayList
saveAccountSettings()
}
}
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
if (newNIP65RelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
if (backupGuard.accept(backupNIP65RelayList, newNIP65RelayList, isEmpty = newNIP65RelayList.tags.isEmpty()) { updateNIP65RelayList(newNIP65RelayList) }) {
backupNIP65RelayList = newNIP65RelayList
saveAccountSettings()
}
@@ -1226,14 +1170,17 @@ class AccountSettings(
fun updateCashuWallet(newWallet: CashuWalletEvent?) {
if (newWallet == null) return
// Replaceable: keep the latest by id (id changes on each re-sign).
if (backupCashuWallet?.id != newWallet.id) {
if (backupGuard.accept(backupCashuWallet, newWallet) { updateCashuWallet(newWallet) }) {
backupCashuWallet = newWallet
saveAccountSettings()
}
}
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
if (newNutzapInfo == null) return
// The guard runs first, so another app wiping the nutzap info is questioned like any
// other lossy rewrite instead of silently clearing the backup.
if (!backupGuard.accept(backupNutzapInfo, newNutzapInfo) { updateNutzapInfo(newNutzapInfo) }) return
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
// (an empty replacement carrying only an `alt` tag). Don't restore it
// on next launch — backing it up would undo clearNutzapInfo() once the
@@ -1242,10 +1189,8 @@ class AccountSettings(
clearNutzapInfo()
return
}
if (backupNutzapInfo?.id != newNutzapInfo.id) {
backupNutzapInfo = newNutzapInfo
saveAccountSettings()
}
backupNutzapInfo = newNutzapInfo
saveAccountSettings()
}
/**
@@ -1255,6 +1200,8 @@ class AccountSettings(
* LocalCache on next launch and resurrect the deleted wallet.
*/
fun clearCashuWallet() {
// A deleted wallet must not be restorable from an open conflict.
backupGuard.drop(backupSlotOf(CashuWalletEvent.KIND))
if (backupCashuWallet != null) {
backupCashuWallet = null
saveAccountSettings()
@@ -1263,30 +1210,18 @@ class AccountSettings(
/** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */
fun clearNutzapInfo() {
backupGuard.drop(backupSlotOf(NutzapInfoEvent.KIND))
if (backupNutzapInfo != null) {
backupNutzapInfo = null
saveAccountSettings()
}
}
/**
* NUT-13 keyset counters live in [CashuPreferences], a dedicated
* SharedPreferences file with synchronous (`commit = true`) writes.
* AccountSettings goes through a 1-second debounce on its own save
* path; the cashu counter cannot tolerate that window because the
* mint persists signed (keyset, blind_message) pairs the moment it
* sees them, so any local lag → "outputs already signed" on retry.
* See [CashuPreferences] for the full rationale.
*/
private val cashuPrefs: CashuPreferences by lazy {
CashuPreferences.forAccount(keyPair.pubKey.toNpub())
}
/**
* Reserve [count] consecutive NUT-13 counters for [keysetId],
* returning the first one. Caller derives `(secret, r)` from
* `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`.
* Persisted synchronously before returning — see [CashuPreferences].
* Persisted synchronously before returning — see [CashuKeysetCounterStore].
*
* One-time migration: when this keyset has a non-zero value in the
* legacy [cashuKeysetCounters] map (from a build that persisted
@@ -1299,95 +1234,87 @@ class AccountSettings(
count: Int,
): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.reserveCounters(keysetId, count)
return cashuCounters.reserve(keysetId, count)
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.peekCounter(keysetId)
return cashuCounters.peek(keysetId)
}
private fun migrateLegacyCashuCounter(keysetId: String) {
val legacy = cashuKeysetCounters[keysetId] ?: return
cashuPrefs.seedCounterIfMissing(keysetId, legacy)
cashuCounters.seedIfMissing(keysetId, legacy)
}
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
if (newNIPA3PaymentTargets == null) return
// Events might be different objects, we have to compare their ids.
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
if (backupGuard.accept(backupNipA3PaymentTargets, newNIPA3PaymentTargets, isEmpty = newNIPA3PaymentTargets.tags.isEmpty()) { updateNIPA3PaymentTargets(newNIPA3PaymentTargets) }) {
backupNipA3PaymentTargets = newNIPA3PaymentTargets
saveAccountSettings()
}
}
fun updateBolt12Offers(newBolt12Offers: Bolt12OfferListEvent?) {
if (newBolt12Offers == null || newBolt12Offers.tags.isEmpty()) return
if (newBolt12Offers == null) return
// Events might be different objects, we have to compare their ids.
if (backupBolt12Offers?.id != newBolt12Offers.id) {
if (backupGuard.accept(backupBolt12Offers, newBolt12Offers, isEmpty = newBolt12Offers.tags.isEmpty()) { updateBolt12Offers(newBolt12Offers) }) {
backupBolt12Offers = newBolt12Offers
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
if (newSearchRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupSearchRelayList?.id != newSearchRelayList.id) {
if (backupGuard.accept(backupSearchRelayList, newSearchRelayList, isEmpty = newSearchRelayList.tags.isEmpty()) { updateSearchRelayList(newSearchRelayList) }) {
backupSearchRelayList = newSearchRelayList
saveAccountSettings()
}
}
fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) {
if (newIndexRelayList == null || newIndexRelayList.tags.isEmpty()) return
if (newIndexRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupIndexRelayList?.id != newIndexRelayList.id) {
if (backupGuard.accept(backupIndexRelayList, newIndexRelayList, isEmpty = newIndexRelayList.tags.isEmpty()) { updateIndexRelayList(newIndexRelayList) }) {
backupIndexRelayList = newIndexRelayList
saveAccountSettings()
}
}
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return
if (newRelayFeedList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayFeedsList?.id != newRelayFeedList.id) {
if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) {
backupRelayFeedsList = newRelayFeedList
saveAccountSettings()
}
}
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
if (newBlockedRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupBlockedRelayList?.id != newBlockedRelayList.id) {
if (backupGuard.accept(backupBlockedRelayList, newBlockedRelayList, isEmpty = newBlockedRelayList.tags.isEmpty()) { updateBlockedRelayList(newBlockedRelayList) }) {
backupBlockedRelayList = newBlockedRelayList
saveAccountSettings()
}
}
fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) {
if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return
if (newTrustedRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupTrustedRelayList?.id != newTrustedRelayList.id) {
if (backupGuard.accept(backupTrustedRelayList, newTrustedRelayList, isEmpty = newTrustedRelayList.tags.isEmpty()) { updateTrustedRelayList(newTrustedRelayList) }) {
backupTrustedRelayList = newTrustedRelayList
saveAccountSettings()
}
}
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
if (newPrivateHomeRelayList == null) return
// Events might be different objects, we have to compare their ids.
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
if (backupGuard.accept(backupPrivateHomeRelayList, newPrivateHomeRelayList, isEmpty = newPrivateHomeRelayList.tags.isEmpty()) { updatePrivateHomeRelayList(newPrivateHomeRelayList) }) {
backupPrivateHomeRelayList = newPrivateHomeRelayList
saveAccountSettings()
}
@@ -1396,50 +1323,45 @@ class AccountSettings(
override fun channelList() = backupChannelList
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
if (newChannelList == null || newChannelList.tags.isEmpty()) return
if (newChannelList == null) return
// Events might be different objects, we have to compare their ids.
if (backupChannelList?.id != newChannelList.id) {
if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) {
backupChannelList = newChannelList
saveAccountSettings()
}
}
fun updateGeohashListTo(newGeohashList: GeohashListEvent?) {
if (newGeohashList == null || newGeohashList.tags.isEmpty()) return
if (newGeohashList == null) return
// Events might be different objects, we have to compare their ids.
if (backupGeohashList?.id != newGeohashList.id) {
if (backupGuard.accept(backupGeohashList, newGeohashList, isEmpty = newGeohashList.tags.isEmpty()) { updateGeohashListTo(newGeohashList) }) {
backupGeohashList = newGeohashList
saveAccountSettings()
}
}
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
if (newHashtagList == null || newHashtagList.tags.isEmpty()) return
if (newHashtagList == null) return
// Events might be different objects, we have to compare their ids.
if (backupHashtagList?.id != newHashtagList.id) {
if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) {
backupHashtagList = newHashtagList
saveAccountSettings()
}
}
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
if (newFavoriteDvmList == null) return
// Events might be different objects, we have to compare their ids.
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
if (backupGuard.accept(backupFavoriteAlgoFeedsList, newFavoriteDvmList, isEmpty = newFavoriteDvmList.tags.isEmpty()) { updateFavoriteAlgoFeedsListTo(newFavoriteDvmList) }) {
backupFavoriteAlgoFeedsList = newFavoriteDvmList
saveAccountSettings()
}
}
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
if (newCommunityList == null) return
// Events might be different objects, we have to compare their ids.
if (backupCommunityList?.id != newCommunityList.id) {
if (backupGuard.accept(backupCommunityList, newCommunityList, isEmpty = newCommunityList.tags.isEmpty()) { updateCommunityListTo(newCommunityList) }) {
backupCommunityList = newCommunityList
saveAccountSettings()
}
@@ -1448,10 +1370,9 @@ class AccountSettings(
override fun ephemeralChatList() = backupEphemeralChatList
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
if (newEphemeralChatList == null) return
// Events might be different objects, we have to compare their ids.
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
if (backupGuard.accept(backupEphemeralChatList, newEphemeralChatList, isEmpty = newEphemeralChatList.tags.isEmpty()) { updateEphemeralChatListTo(newEphemeralChatList) }) {
backupEphemeralChatList = newEphemeralChatList
saveAccountSettings()
}
@@ -1464,8 +1385,7 @@ class AccountSettings(
// so an empty `tags` is NOT an empty list — guard only on null.
if (newRelayGroupList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayGroupList?.id != newRelayGroupList.id) {
if (backupGuard.accept(backupRelayGroupList, newRelayGroupList) { updateRelayGroupListTo(newRelayGroupList) }) {
backupRelayGroupList = newRelayGroupList
saveAccountSettings()
}
@@ -1478,27 +1398,25 @@ class AccountSettings(
// so an empty `tags` is NOT an empty list — guard only on null.
if (newConcordList == null) return
if (backupConcordList?.id != newConcordList.id) {
if (backupGuard.accept(backupConcordList, newConcordList) { updateConcordListTo(newConcordList) }) {
backupConcordList = newConcordList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
if (trustProviderList == null) return
// Events might be different objects, we have to compare their ids.
if (backupTrustProviderList?.id != trustProviderList.id) {
if (backupGuard.accept(backupTrustProviderList, trustProviderList, isEmpty = trustProviderList.tags.isEmpty()) { updateTrustProviderListTo(trustProviderList) }) {
backupTrustProviderList = trustProviderList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
if (newMuteList == null) return
// Events might be different objects, we have to compare their ids.
if (backupMuteList?.id != newMuteList.id) {
if (backupGuard.accept(backupMuteList, newMuteList, isEmpty = newMuteList.tags.isEmpty()) { updateMuteList(newMuteList) }) {
backupMuteList = newMuteList
saveAccountSettings()
}
@@ -1508,11 +1426,15 @@ class AccountSettings(
appSettings: AppSpecificDataEvent?,
newSyncedSettings: AccountSyncedSettingsInternal,
) {
if (appSettings == null || appSettings.content.isEmpty()) return
if (appSettings == null) return
// Events might be different objects, we have to compare their ids.
if (backupAppSpecificData?.id != appSettings.id) {
if (backupGuard.accept(backupAppSpecificData, appSettings, isEmpty = appSettings.content.isBlank()) { updateAppSpecificData(appSettings, newSyncedSettings) }) {
backupAppSpecificData = appSettings
// An emptied blob (signed here or accepted by the user) has no settings to merge.
if (appSettings.content.isBlank()) {
saveAccountSettings()
return
}
syncedSettings.updateFrom(newSyncedSettings)
// Null means an older client rewrote the blob without this key — leave the
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames
import com.vitorpamplona.amethyst.commons.model.navigation.toNames
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -49,6 +51,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
@@ -369,6 +373,35 @@ class EventBroadcaster(
}
}
/**
* Publishes a re-signed backup that replaces a lossy version from another app. It goes
* where a normal save of that event goes; lists of the user's own relays (NIP-65, DM,
* key package) and the wallet go everywhere, and also to every relay the restored list
* names, since the lossy version may have removed them from the outbox set. Profiles and
* nutzap info go everywhere too, like their normal saves.
*/
fun sendRestoredVersion(event: Event) {
when (event) {
is AdvertisedRelayListEvent -> sendEverywhereAnd(event, event.relays().mapTo(mutableSetOf()) { it.relayUrl })
is ChatMessageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
is KeyPackageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet())
is CashuWalletEvent -> sendLiterallyEverywhere(event)
// Profiles and nutzap info are normally saved everywhere so others can find them;
// the restore must reach the same relays or they keep serving the lossy version.
is MetadataEvent -> sendLiterallyEverywhere(event)
is NutzapInfoEvent -> sendEverywhereAnd(event, event.relays().toSet())
else -> sendMyPublicAndPrivateOutbox(event)
}
}
private fun sendEverywhereAnd(
event: Event,
extraRelays: Set<NormalizedRelayUrl>,
) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + extraRelays)
account.cache.justConsumeMyOwnEvent(event)
}
fun sendLiterallyEverywhere(event: Event) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
account.cache.justConsumeMyOwnEvent(event)
@@ -28,16 +28,16 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSig
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -0,0 +1,92 @@
/*
* 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.backups
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.utils.Log
/**
* How an undecided backup conflict is written to storage.
*
* A conflict is a question put to the user, and the backup it holds back stays held back until
* they answer. That outlives the process it was raised in, so the three events it is made of —
* the saved version, the incoming one, and the change that caused it — are stored and read back.
*
* The diff is **not** stored. It is recomputed from the two versions on load, so a conflict whose
* incoming version no longer removes anything (the other app put it back) simply stops being one
* instead of being resurrected from a stale description of itself.
*
* Kept out of `LocalPreferences` so it is reachable from a plain JVM test.
*/
object BackupConflictStorage {
// Every settings save writes the open conflicts, and each can hold a follow list with
// thousands of entries: reuse the last encoding while the conflicts themselves are the same.
@Volatile
private var lastEncoded: Pair<List<String>, String>? = null
fun encode(conflicts: List<Triple<Event, Event, Event>>): String {
val ids = conflicts.flatMap { (saved, incoming, cause) -> listOf(saved.id, incoming.id, cause.id) }
lastEncoded?.let { (cachedIds, encoded) -> if (cachedIds == ids) return encoded }
return encodeUncached(conflicts).also { lastEncoded = ids to it }
}
private fun encodeUncached(conflicts: List<Triple<Event, Event, Event>>): String =
JsonMapper.toJson(
conflicts.map { (saved, incoming, cause) ->
listOf(
OptimizedJsonMapper.toJson(saved),
OptimizedJsonMapper.toJson(incoming),
OptimizedJsonMapper.toJson(cause),
)
},
)
/**
* Unreadable entries are dropped one at a time: a conflict that cannot be parsed is not one
* the user can be asked about, and it must not take the readable ones down with it.
*/
fun decode(stored: String?): List<Triple<Event, Event, Event>> {
if (stored.isNullOrBlank()) return emptyList()
val rows =
try {
JsonMapper.fromJson<List<List<String>>>(stored)
} catch (e: Exception) {
Log.w("BackupConflictStorage", "Could not read the stored backup conflicts", e)
return emptyList()
}
return rows.mapNotNull { row ->
if (row.size != 3) return@mapNotNull null
try {
Triple(
OptimizedJsonMapper.fromJson(row[0]),
OptimizedJsonMapper.fromJson(row[1]),
OptimizedJsonMapper.fromJson(row[2]),
)
} catch (e: Exception) {
Log.w("BackupConflictStorage", "Could not read a stored backup conflict", e)
null
}
}
}
}
@@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ActivityEntry
import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ActivityLog
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -25,6 +25,7 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
/**
* Per-account Cashu state that needs durable, synchronous persistence —
@@ -70,10 +71,10 @@ import com.vitorpamplona.amethyst.Amethyst
*/
class CashuPreferences(
private val prefs: SharedPreferences,
) {
) : CashuKeysetCounterStore {
/** Inspect the next free counter for [keysetId] without advancing it. */
@Synchronized
fun peekCounter(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
/**
* Atomically reserve [count] consecutive NUT-13 counters for
@@ -83,12 +84,12 @@ class CashuPreferences(
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun reserveCounters(
override fun reserve(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = peekCounter(keysetId)
val current = peek(keysetId)
val next = current + count.toLong()
prefs.edit(commit = true) { putLong(counterKey(keysetId), next) }
return current
@@ -103,12 +104,12 @@ class CashuPreferences(
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun seedCounterIfMissing(
override fun seedIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
val current = peekCounter(keysetId)
val current = peek(keysetId)
if (current >= legacyValue) return
prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) }
}
@@ -111,11 +111,26 @@ class AppSpecificState(
getAppSpecificDataFlow().collect {
try {
Log.d("AccountRegisterObservers") { "Updating AppSpecificData for ${signer.pubKey}" }
(it.note.event as? AppSpecificDataEvent)?.let {
val decrypted = signer.decrypt(it.content, it.pubKey)
(it.note.event as? AppSpecificDataEvent)?.let { event ->
// A blob another app emptied has nothing to decrypt, so it used to die
// in the catch below — yet an empty version is exactly the wipe the
// backup guard exists to question, and [AccountSettings] already
// accepts one (`isEmpty`). Without this it never got there: the guard
// was never called, no conflict was raised, and the NIP-78 card could
// not appear for the case it was written for.
//
// The settings handed over are never merged — updateAppSpecificData
// returns before that whenever the content is empty — so defaults
// here cannot overwrite the real ones.
if (event.content.isBlank()) {
settings.updateAppSpecificData(event, AccountSyncedSettingsInternal())
return@let
}
val decrypted = signer.decrypt(event.content, event.pubKey)
try {
val syncedSettings = JsonMapper.fromJson<AccountSyncedSettingsInternal>(decrypted)
settings.updateAppSpecificData(it, syncedSettings)
settings.updateAppSpecificData(event, syncedSettings)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding latestAppSpecificData from Preferences", e)
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.nipB7Blossom
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
@@ -70,8 +70,8 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_deny_always
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_hide_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_not_now
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_show_event
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -43,12 +43,12 @@ import com.vitorpamplona.amethyst.commons.resources.crashreport_found
import com.vitorpamplona.amethyst.commons.resources.crashreport_found_send
import com.vitorpamplona.amethyst.commons.resources.would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -42,6 +42,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
@@ -94,6 +95,18 @@ abstract class FlowProgressForegroundService<T> : Service() {
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
protected open val refreshMs: Long? = null
/**
* How long to keep the service (and whatever it holds up) alive after [isActive] turns
* false, before stopping. Lets work that was just handed off asynchronously — e.g. a
* broadcast still leaving through the relay pool — finish before the process loses its
* foreground protection and gets frozen. A new active emission during the grace keeps
* the service running. Asked each time the work drains, so it can depend on how it did.
*/
protected open fun stopGraceMs(): Long = 0L
/** The card shown during [stopGraceMs]; null keeps the last one. */
protected open fun renderDraining(): Content? = null
protected abstract fun state(): StateFlow<T>
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
@@ -222,9 +235,15 @@ abstract class FlowProgressForegroundService<T> : Service() {
onStarted()
watchJob =
scope.launch {
state().collect { value ->
state().collectLatest { value ->
onEmission(value)
if (!isActive(value)) {
// collectLatest: a new active emission cancels this pending stop.
val grace = stopGraceMs()
if (grace > 0) {
renderDraining()?.let { notify(buildNotification(it)) }
delay(grace)
}
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
@@ -246,7 +265,7 @@ abstract class FlowProgressForegroundService<T> : Service() {
private fun startForegroundCompat(value: T) {
ensureChannel()
val notification = buildNotification(value)
val notification = buildNotification(render(value))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, fgsType)
} else {
@@ -254,18 +273,19 @@ abstract class FlowProgressForegroundService<T> : Service() {
}
}
private fun updateNotification(value: T) {
private fun updateNotification(value: T) = notify(buildNotification(render(value)))
private fun notify(notification: Notification) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(notificationId, buildNotification(value))
manager.notify(notificationId, notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
private fun buildNotification(value: T): Notification {
val content = render(value)
private fun buildNotification(content: Content): Notification {
val style =
when (val bar = content.bar) {
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvi
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.startsWithIgnoreCase
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@@ -44,18 +45,49 @@ import kotlin.coroutines.cancellation.CancellationException
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val imageLoader: ImageLoader,
private val blossomServerResolver: () -> BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
private val networkFetcher: (url: String, diskCacheKey: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
try {
val urlResult = blossomServerResolver().findServers(data.toString())
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
val uri = data.toString()
val key = options.diskCacheKey ?: diskCacheKey(uri)
// A blob already on disk is served without resolving: resolution may HEAD
// servers and wait on relays, and fails outright offline, where the bytes are
// still sitting in the cache. NetworkFetcher reads the snapshot under [key]
// before it ever looks at the url.
val onDisk = options.diskCachePolicy.readEnabled && imageLoader.diskCache?.openSnapshot(key)?.use { true } ?: false
val fromDisk =
if (onDisk) {
// Evicted between the check and the read: the fetcher would try the
// network with the unresolvable `blossom:` uri, so resolve after all.
try {
networkFetcher(uri, key).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} else {
null
}
fromDisk ?: networkFetcher(blossomServerResolver().findServers(uri)?.serverUrl ?: uri, key).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
companion object {
/**
* Blossom blobs are content-addressed, so the sha256 names the bytes wherever they
* were served from. Keying on it (instead of the resolved server URL) finds the
* blob again after a restart, a probe flip or a different server answering.
*/
fun diskCacheKey(uri: String): String = BlossomUri.parse(uri)?.let { "blossom:${it.sha256}" } ?: uri
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: () -> BlossomServerResolver,
@@ -78,20 +110,19 @@ class BlossomFetcher(
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 ->
return BlossomFetcher(options, data, imageLoader, blossomServerResolver) { url, diskCacheKey ->
val keyedOptions = options.copy(diskCacheKey = diskCacheKey)
readAuthAware(url, readAuth) { authHeader ->
NetworkFetcher(
url = url,
options = options.withAuthHeader(authHeader),
options = keyedOptions.withAuthHeader(authHeader),
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = concurrentRequestStrategyLazy,
)
// Keyed on the resolved server url, which is what the NetworkFetcher above
// caches under -- not on the `blossom:` uri the request came in as.
}.onSystemFileSystem(options.diskCacheKey ?: url)
}.onSystemFileSystem(diskCacheKey)
}
}
@@ -49,8 +49,6 @@ import com.vitorpamplona.amethyst.commons.service.image.ThumbHashFetcher
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.images.BlossomFetcher
import com.vitorpamplona.amethyst.service.images.ProfilePictureFetcher
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -87,9 +85,10 @@ class ImageLoaderSetup {
// 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.
// no-ops. Shared across all three network-backed factories so the same
// key requested through any of them (e.g. a feed image and a profile
// picture with the same URL) collapses onto one download. `blossom:`
// blobs are keyed by their sha256 instead (see BlossomFetcher.diskCacheKey).
val concurrentRequests = DeDupeConcurrentRequestStrategy()
SingletonImageLoader.setUnsafe(
@@ -36,6 +36,8 @@ import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor.Companion.PROFILE_PICTURE
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
@@ -121,7 +123,8 @@ class ProfilePictureFetcher(
NetworkFetcher(
url = data.url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(data.url).asNetworkClient() },
// Marked so the local Blossom cache bridge can honour "profile pictures only".
networkClient = lazy { LocalBlossomMediaCallFactory(networkClient(data.url), PROFILE_PICTURE).asNetworkClient() },
diskCache = diskCacheLazy,
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
@@ -38,14 +38,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.commons.ui.theme.Size75dp
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.commons.ui.theme.videoGalleryModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -43,7 +43,7 @@ import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.captions_off
import com.vitorpamplona.amethyst.commons.resources.captions_unnamed_track
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
@@ -41,11 +41,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.enter_picture_in_picture
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -48,8 +48,8 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberProgressStateWithTickCount
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -43,12 +43,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.mute_button
import com.vitorpamplona.amethyst.commons.resources.muted_button
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.delay
@Preview
@@ -50,16 +50,16 @@ import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.playback_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_fullscreen
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -40,12 +40,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.ui.note.EnablePiP
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.note.EnablePiP
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -44,11 +44,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pause
import com.vitorpamplona.amethyst.commons.resources.play
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PlayIconSize
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PlayIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -45,9 +45,9 @@ import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlaybackSpeedState
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -33,8 +33,8 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@OptIn(UnstableApi::class)
@Preview
@@ -64,6 +64,12 @@ import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.model.VideoButtonLocation
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.service.cast.CastRequest
@@ -78,12 +84,6 @@ import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@@ -52,12 +52,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.video_download_has_started_toast
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.launch
@Preview
@@ -42,12 +42,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
@Preview
@Composable
@@ -38,10 +38,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.skip_back
import com.vitorpamplona.amethyst.commons.resources.skip_forward
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
private val FadeIn = fadeIn()
private val FadeOut = fadeOut()
@@ -57,10 +57,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.call_settings_video_quality
import com.vitorpamplona.amethyst.commons.resources.video_quality_auto
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
@@ -42,8 +42,8 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
@@ -40,6 +40,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.state.rememberMuteButtonState
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.commons.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
@@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.constrain
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier
@OptIn(UnstableApi::class)
@Composable
@@ -39,6 +39,7 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.http.DynamicCallFactory
import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
@@ -59,7 +60,11 @@ class PlaybackService : MediaSessionService() {
okHttpClient: DynamicCallFactory,
blossomServerResolver: BlossomServerResolver,
): MediaSessionPool {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
// Marked as media so the local Blossom cache may serve these downloads.
val dataSourceFactory =
OkHttpDataSource
.Factory(okHttpClient)
.setDefaultRequestProperties(mapOf(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER to LocalBlossomCacheRedirectInterceptor.MEDIA))
val resolvingDataSourceFactory: DataSource.Factory =
ResolvingDataSource.Factory(
@@ -22,15 +22,20 @@ package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.PowerManager
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPhase
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -44,6 +49,17 @@ import kotlinx.coroutines.launch
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
* Staying schedulable is not enough on its own once the user also locks the
* screen or leaves the app, so while it runs the service also:
* - holds a partial wake lock, or the CPU suspends with the screen off and the
* nonce search stalls until the phone is unlocked;
* - keeps the relay pool (and Tor) up. The UI stops holding them ~30 s after
* the app is backgrounded, so a post mined after that would only reach the
* in-memory outbox — while its checkpoint and draft were already deleted —
* and die with the process;
* - lingers [PUBLISH_GRACE_MS] after the queue drains, so the event that was
* just handed to the pool actually leaves before the process is frozen.
*
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
* to that card.
@@ -68,17 +84,60 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private var sessionTotal = 0
private var lastQueueSize = 0
// Whether the last non-empty queue still had a job publishing: a queue that drains by
// publishing needs the grace for the event to leave; one drained by cancel does not.
private var drainedByPublishing = false
override fun stopGraceMs() = if (drainedByPublishing) PUBLISH_GRACE_MS else 0L
override fun renderDraining() = Content(stringRes(this, R.string.pow_notification_sending), null, Bar.Indeterminate)
// Benchmarked once per service run (~250 ms, cached by the estimator).
@Volatile
private var hashRate: Double? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
running = true
wakeLock =
runCatching {
(getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:pow-mining")
.apply {
setReferenceCounted(false)
acquire(WAKE_LOCK_TIMEOUT_MS)
}
}.onFailure { Log.w(TAG, "Could not acquire the mining wake lock", it) }
.getOrNull()
// Re-arm the timeout while the service lives: before Android 14 there is no FGS budget
// and a hard nonce can mine for hours, but a lock with no timeout could outlive a
// service that died without onDestroy.
scope.launch {
while (true) {
delay(WAKE_LOCK_TIMEOUT_MS / 2)
wakeLock?.let { if (it.isHeld) it.acquire(WAKE_LOCK_TIMEOUT_MS) }
}
}
// Mirrors what the resumed UI collects (AccountScreen's ManageRelayServices +
// ManageWebOkHttp): subscribed, they keep the relay pool connected and, through
// the connector's combine and the proxy-port provider, Tor up.
val app = Amethyst.instance
scope.launch(Dispatchers.IO) {
launch { app.relayProxyClientConnector.relayServices.collect {} }
launch { app.okHttpClients.defaultHttpClient.collect {} }
launch { app.okHttpClients.defaultHttpClientWithoutProxy.collect {} }
}
}
override fun onDestroy() {
running = false
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
@@ -97,6 +156,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
}
override fun onEmission(value: ImmutableList<PoWJobState>) {
if (value.isNotEmpty()) drainedByPublishing = value.any { it.phase == PoWJobPhase.PUBLISHING }
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
lastQueueSize = value.size
}
@@ -155,6 +215,13 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
private const val PROGRESS_REFRESH_MS = 30_000L
// Time for a just-mined post to connect, send and get its OK before the service
// lets go of the relays and the process becomes freezable.
private const val PUBLISH_GRACE_MS = 20_000L
// Safety net only: onDestroy releases it, and the service re-arms it while alive.
private const val WAKE_LOCK_TIMEOUT_MS = 10 * 60 * 1000L
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
@Volatile
private var running = false
@@ -97,15 +97,15 @@ import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_read_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_send_dm
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_thread
import com.vitorpamplona.amethyst.commons.resources.relay_auth_why_thread_with
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -25,9 +25,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.payment_required_title
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@Composable
@@ -42,13 +42,13 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.notify_block_relay
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@Composable
fun NotifyRequestDialog(
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cachedDvmAnnouncements
import com.vitorpamplona.amethyst.commons.relayClient.apps.recommendations.ProfileAppRecommendationsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.articles.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
@@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssem
import com.vitorpamplona.amethyst.commons.relayClient.video.VideoFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.wallet.OnchainZapsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.workouts.WorkoutsFilterAssembler
import com.vitorpamplona.amethyst.model.cachedDvmAnnouncements
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayClient.account.metadata.filterBasicAccountInfoFromKeys
import com.vitorpamplona.amethyst.commons.relayClient.account.metadata.filterBookmarksAndReportsFromKey
@@ -38,6 +39,24 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Which relays to ask for an account's own events, in order of what we actually know.
*
* Split out from the manager so the precedence can be tested without standing up an account:
* the rule is the whole fix, and the case that broke — every set but [defaults] empty — is the
* one no live account reproduces on demand.
*/
fun pickAccountDataRelays(
home: Set<NormalizedRelayUrl>,
inbox: Set<NormalizedRelayUrl>,
defaults: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> =
when {
home.isNotEmpty() -> home
inbox.isNotEmpty() -> inbox
else -> defaults
}
/**
* Each account's own profile, lists and recent posts — for **every** logged-in account, in one
* subscription.
@@ -59,13 +78,40 @@ class AccountMetadataEoseManager(
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
/**
* Where to look for an account's **own** profile, follow list and lists.
*
* Normally its home relays: where it publishes is where its own events are. But home relays
* are NIP-65 write relays (plus private storage and local ones), and a kind:10002 that lists
* none — read-only, or simply wrong — is a statement about publishing, not about where the
* account's existing events can be found. Keyed on that set alone, such an account joined no
* relay bucket at all and this subscription asked **nothing** on its behalf: its profile,
* follows and every backed-up list stayed frozen at whatever was last stored, forever, with
* nothing on screen to say why. A follow list replaced from another client never arrived, so
* the backup guard could not even raise the conflict it exists to raise.
*
* The fallbacks only widen where we *read*; they never redirect a publish. Its inbox relays
* come first — a client that rewrote these lists most likely also put them where the account
* says to reach it — and the app's event finders last, which is what "we don't know" means
* everywhere else.
*
* Deliberately not fixed in [relayListOrDefaultsWhenUnknown]: an empty list there means "they
* told us: nothing", and defaulting it would override an explicit choice about publishing.
*/
fun relaysToQuery(query: AccountQueryState): Set<NormalizedRelayUrl> =
pickAccountDataRelays(
home = relayFlow(query).value,
inbox = query.account.nip65RelayList.inboxFlow.value,
defaults = Constants.eventFinderRelays,
)
override fun updateFilter(
keys: List<AccountQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val accountsPerRelay = mutableMapOf<NormalizedRelayUrl, MutableList<AccountQueryState>>()
keys.forEach { key ->
relayFlow(key).value.forEach { relay ->
relaysToQuery(key).forEach { relay ->
accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key)
}
}
@@ -117,6 +163,13 @@ class AccountMetadataEoseManager(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest { invalidateFilters() }
},
// The inbox list is watched too because [relaysToQuery] falls back to it:
// an account with no home relays would otherwise keep querying whatever
// the fallback resolved to at startup, deaf to its own NIP-65 changing.
key.account.scope.launch(Dispatchers.IO) {
key.account.nip65RelayList.inboxFlow
.collectLatest { invalidateFilters() }
},
)
}
}
@@ -45,11 +45,11 @@ import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_conn_t
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_reconnects
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_wakelock
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okio.Buffer
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
@@ -97,24 +99,29 @@ class UsageCountingInterceptor(
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache
// hits to 127.0.0.1) never touches the radio: counting it would inflate
// the network numbers — and could trip the background-data alert — for
// exactly the users who cache aggressively to SAVE data.
// Loopback traffic never touches the radio: counting it would inflate the
// network numbers — and could trip the background-data alert — for exactly
// the users who cache aggressively to SAVE data. This interceptor is the
// outermost one, so it sees URLs BEFORE LocalBlossomCacheRedirectInterceptor
// rewrites them to 127.0.0.1; the host that was actually contacted is only
// known from the response's request.
if (isLoopback(request.url.host)) return chain.proceed(request)
val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
bursts?.onHttpActivity()
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
if (requestBytes > 0) {
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
}
val startedAtMs = nowMs()
val response = chain.proceed(request)
val response =
try {
chain.proceed(request)
} catch (e: IOException) {
countRequest(request, role)
throw e
}
if (isLoopback(response.request.url.host)) return response
countRequest(request, role)
return response
.newBuilder()
.body(
@@ -131,6 +138,19 @@ class UsageCountingInterceptor(
).build()
}
private fun countRequest(
request: Request,
role: String,
) {
accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1)
bursts?.onHttpActivity()
val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L
if (requestBytes > 0) {
accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes)
}
}
companion object {
/** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */
fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]"
@@ -37,9 +37,9 @@ import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_sent_title
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.commons.scheduledposts.extractContentPreview
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
/**
* Android [ScheduledPostNotifier]: posts a user-visible system notification when a
@@ -27,7 +27,6 @@ import androidx.core.net.toUri
import androidx.media3.common.MimeTypes
import com.davotoula.lightcompressor.video.GifToMp4Converter
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log
import id.zelory.compressor.Compressor
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.service.image.BlurhashWrapper
import com.vitorpamplona.amethyst.commons.service.image.ThumbhashWrapper
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
import com.vitorpamplona.amethyst.service.uploads.isAvif
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log
import java.nio.ByteBuffer
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required
@@ -40,8 +42,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
@@ -28,17 +28,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
@@ -46,10 +52,20 @@ class BlossomServerResolver(
val httpClientBuilder: IRoleBasedHttpClientBuilder,
val useLocalBlossomCache: () -> Boolean = { false },
val localCacheProbe: LocalBlossomCacheProbe? = null,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
// Unresolvable URIs, with when they failed. Without this every retry — each ExoPlayer
// load attempt blocks a loader thread on it — paid the full resolution timeout again.
private val missCache = LruCache<String, Long>(200)
// One resolution per URI at a time: the feed preview, Coil and the player asking for the
// same `blossom:` URI together share it instead of each firing its own HEADs and relay
// subscriptions. Runs in [scope] so a caller that goes away doesn't cancel it for the rest.
private val inFlight = ConcurrentHashMap<String, Deferred<BlossomUriServer?>>()
class BlossomUriServer(
val uri: BlossomUri,
val serverUrl: String,
@@ -57,41 +73,71 @@ class BlossomServerResolver(
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
// Bumped by [clearCaches]. A resolution that started before a clear (e.g. it picked the
// local cache just before the cache went down) must not write its stale answer back.
private val generation = AtomicInteger(0)
/** Forgets every resolution, e.g. when the local cache comes up or goes down. */
fun clearCaches() {
generation.incrementAndGet()
uriToUrlCache.evictAll()
missCache.evictAll()
blossomHitCache.cache.evictAll()
}
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
missCache[uriStr]?.let { failedAt ->
if (TimeUtils.nowMillis() - failedAt < MISS_TTL_MS) return null
}
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
val result =
withContext(Dispatchers.IO) {
withTimeoutOrNull(10000) {
findServersInner(uriStr)
val resolution =
inFlight.computeIfAbsent(uriStr) {
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
scope.async(Dispatchers.IO) {
val startedAt = generation.get()
try {
val result = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { findServersInner(uriStr) }
// Cleared while resolving: answer the waiting callers, remember nothing.
if (startedAt == generation.get()) {
if (result != null) {
uriToUrlCache.put(uriStr, result)
} else {
missCache.put(uriStr, TimeUtils.nowMillis())
}
}
result
} finally {
inFlight.remove(uriStr)
}
}
}
if (result != null) {
uriToUrlCache.put(uriStr, result)
}
return result
return resolution.await()
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
val expectedMimeType = mimeTypeMap[uri.extension]
// Same rule as the HTTP-level bridge, which Tor-routed clients don't carry: media the
// user sends through Tor must not be handed to the local cache, which would fetch it
// from the origin outside Tor.
if (useLocalBlossomCache() && !isTorRouted(uri, expectedMimeType) && localCacheProbe?.isAvailable() == true) {
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
}
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
// Bounded well under RESOLVE_TIMEOUT_MS so a hint server that drops packets
// leaves time for the author's own server list below.
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size, XS_TIMEOUT_MS)
if (workingUrl != null) {
return BlossomUriServer(uri, workingUrl)
}
@@ -135,13 +181,20 @@ class BlossomServerResolver(
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
timeoutMs: Long = RESOLVE_TIMEOUT_MS,
): String? =
firstNotNullOrNullAsync(servers, 10000) {
firstNotNullOrNullAsync(servers, timeoutMs) {
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
client(url, expectedMimeType)
}
}
/** Whether this blob's media type would be fetched through Tor from a regular (clearnet) server. */
private fun isTorRouted(
uri: BlossomUri,
mimeType: String?,
): Boolean = client(uri.toServerUrl() ?: "https://blossom.invalid/${uri.filename()}", mimeType).proxy != null
fun client(
url: String,
mimeType: String?,
@@ -153,9 +206,13 @@ class BlossomServerResolver(
else -> httpClientBuilder.okHttpClientForPreview(url)
}
fun canResolve(scheme: String) = scheme == SCHEME
fun canResolve(scheme: String) = scheme.equals(SCHEME, ignoreCase = true)
companion object {
const val SCHEME = "blossom"
private const val RESOLVE_TIMEOUT_MS = 10_000L
private const val XS_TIMEOUT_MS = 4_000L
private const val MISS_TTL_MS = 30_000L
}
}
@@ -32,6 +32,7 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
@@ -48,6 +49,9 @@ class LocalBlossomCacheProbe(
@Volatile
private var cachedAtMs: Long = 0L
// Bumped by [markUnavailable]; lets a probe that raced it discard its stale positive result.
private val unavailableMarks = AtomicInteger(0)
private val _available = MutableStateFlow(false)
val available: StateFlow<Boolean> = _available
@@ -66,7 +70,11 @@ class LocalBlossomCacheProbe(
return@withLock _available.value
}
val startedAt = unavailableMarks.get()
val newResult = probe()
// A refused connection reported while this probe was in flight is newer evidence
// than a HEAD that may have succeeded just before the cache went away.
if (newResult && startedAt != unavailableMarks.get()) return@withLock false
_available.value = newResult
cachedAtMs = currentTimeMs()
newResult
@@ -80,6 +88,17 @@ class LocalBlossomCacheProbe(
cachedAtMs = 0L
}
/**
* Records that the cache just refused a connection. Flipping [available] right away turns
* the bridge off everywhere it is read, instead of letting every sha256 URL keep failing
* against the dead loopback port until the positive TTL runs out and someone re-probes.
*/
fun markUnavailable() {
unavailableMarks.incrementAndGet()
cachedAtMs = currentTimeMs()
_available.value = false
}
// Confined to Dispatchers.IO because callers reach this through suspend
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
// on the main dispatcher: building the OkHttp client and issuing the HEAD
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
@@ -56,13 +56,18 @@ class ServerHeadCache {
client(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
cache.put(url, HasFile.NoFile)
// Only a definitive answer is remembered. A 5xx, 429 or auth challenge is
// transient: caching it would hide this server for the app's lifetime.
if (response.code == 404 || response.code == 410) {
cache.put(url, HasFile.NoFile)
}
return HasFile.NoFile
}
// Retrieve the "Content-Length" header
val contentLength = response.header("Content-Length")?.toLongOrNull()
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
// type/subtype only: a `; charset=binary` parameter must not fail the match below.
val mimeType = response.header("Content-Type")?.toMediaTypeOrNull()?.let { "${it.type}/${it.subtype}" }
return if (contentLength != null && mimeType != null) {
val result = HasFile.TypeAndSize(mimeType, contentLength)
@@ -75,7 +80,8 @@ class ServerHeadCache {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(url, HasFile.NoFile)
// Offline, DNS failure, timeout: says nothing about the server having the file,
// so it is not cached — a brief outage must not hide the blob until eviction.
return HasFile.NoFile
}
}
@@ -104,7 +110,7 @@ class ServerHeadCache {
if (result.size == expectedSize) {
return url
}
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
if (expectedSize == null && result.size > 0 && result.mimeType.equals(expectedMimeType, ignoreCase = true)) {
return url
}
}
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.service.uploads.hls
import android.content.Context
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
@@ -29,6 +29,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
@@ -39,7 +40,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETT
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
@@ -78,6 +78,19 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_create_and_add_inv
import com.vitorpamplona.amethyst.commons.resources.lightning_invoice
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -86,25 +99,12 @@ 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
import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -32,6 +32,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
@@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
@@ -45,12 +45,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.copy_stack_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg
import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg2
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import kotlinx.coroutines.launch
import java.io.PrintWriter
import java.io.StringWriter
@@ -29,6 +29,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
@@ -40,8 +42,6 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_caption
import com.vitorpamplona.amethyst.commons.resources.add_caption_example
@@ -70,21 +71,20 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_description
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -75,13 +75,13 @@ import com.vitorpamplona.amethyst.commons.resources.twitter
import com.vitorpamplona.amethyst.commons.resources.twitter_proof_url_template
import com.vitorpamplona.amethyst.commons.resources.username
import com.vitorpamplona.amethyst.commons.resources.website_url
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
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
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
@@ -40,7 +41,6 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
@@ -29,9 +29,9 @@ import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_body
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.uploads.ConfirmationCallbacks
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun StrippingFailureDialog(confirmation: SuspendableConfirmation) {
@@ -63,20 +63,20 @@ import com.vitorpamplona.amethyst.commons.resources.bolt12_offers_section_explai
import com.vitorpamplona.amethyst.commons.resources.delete_bolt12_offer
import com.vitorpamplona.amethyst.commons.resources.invalid_bolt12_offer
import com.vitorpamplona.amethyst.commons.resources.no_bolt12_offers_message
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun Bolt12OffersScreen(
@@ -61,6 +61,15 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.mediaServers.ui.MediaServerEditField
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState
import com.vitorpamplona.amethyst.commons.relays.ui.draggableRelayItem
import com.vitorpamplona.amethyst.commons.relays.ui.relayDragHandle
import com.vitorpamplona.amethyst.commons.relays.ui.rememberRelayDragState
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_a_blossom_server
import com.vitorpamplona.amethyst.commons.resources.add_media_server
@@ -85,20 +94,15 @@ import com.vitorpamplona.amethyst.commons.resources.media_servers_upload_section
import com.vitorpamplona.amethyst.commons.resources.my_blossom_data
import com.vitorpamplona.amethyst.commons.resources.no_blossom_server_message
import com.vitorpamplona.amethyst.commons.resources.use_default_servers
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.warningColor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.utils.Rfc3986
import org.jetbrains.compose.resources.StringResource
@@ -57,13 +57,13 @@ import com.vitorpamplona.amethyst.commons.resources.local_blossom_cache_profile_
import com.vitorpamplona.amethyst.commons.resources.media_servers
import com.vitorpamplona.amethyst.commons.resources.use_local_blossom_cache
import com.vitorpamplona.amethyst.commons.resources.use_local_blossom_cache_caption
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
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
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun AllMediaServersScreen(
@@ -212,7 +212,7 @@ fun MediaCacheSection(accountViewModel: AccountViewModel) {
*/
@Composable
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
val probeAvailable by accountViewModel.localBlossomCacheDetected
.collectAsStateWithLifecycle()
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
@@ -98,6 +98,7 @@ import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.blossom_delete_from_host
@@ -127,16 +128,15 @@ import com.vitorpamplona.amethyst.commons.resources.manage_stored_files_empty
import com.vitorpamplona.amethyst.commons.resources.my_blossom_data
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.resources.retry
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import kotlinx.coroutines.launch
@@ -62,6 +62,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.mediaServers.ui.MediaServerEditField
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_a_blossom_server
import com.vitorpamplona.amethyst.commons.resources.blossom_import_add_url_label
@@ -81,14 +83,13 @@ import com.vitorpamplona.amethyst.commons.resources.blossom_import_sources_secti
import com.vitorpamplona.amethyst.commons.resources.blossom_import_start_button
import com.vitorpamplona.amethyst.commons.resources.blossom_import_title
import com.vitorpamplona.amethyst.commons.resources.delete_media_server
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
/** Vibrant palette for server monograms; picked deterministically from the host name. */
private val ImportMonogramColors =
@@ -25,6 +25,8 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -57,10 +57,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.blossom_sync_done
import com.vitorpamplona.amethyst.commons.resources.blossom_syncing
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import kotlinx.coroutines.delay
/** How long the finished "Sync complete" banner lingers before it auto-dismisses. */
@@ -22,11 +22,12 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.file_server
import com.vitorpamplona.amethyst.commons.resources.file_server_description
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import kotlinx.collections.immutable.toImmutableList
@@ -69,23 +69,23 @@ import com.vitorpamplona.amethyst.commons.resources.nests_servers_relay_label
import com.vitorpamplona.amethyst.commons.resources.nests_servers_remove
import com.vitorpamplona.amethyst.commons.resources.nests_servers_title
import com.vitorpamplona.amethyst.commons.resources.nests_servers_use_defaults
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter
import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent
@@ -61,19 +61,19 @@ import com.vitorpamplona.amethyst.commons.resources.payment_target_type
import com.vitorpamplona.amethyst.commons.resources.payment_targets
import com.vitorpamplona.amethyst.commons.resources.payment_targets_explainer
import com.vitorpamplona.amethyst.commons.resources.payment_targets_section_explainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
@Composable
@@ -38,8 +38,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.record_a_message_description
import com.vitorpamplona.amethyst.ui.components.ToggleableBox
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.components.ToggleableBox
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -41,7 +41,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.record_a_message
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@Composable
fun RecordVoiceButton(

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