diff --git a/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md new file mode 100644 index 0000000000..b7cc7e9823 --- /dev/null +++ b/amethyst/plans/2026-09-22-replaceable-backup-conflicts.md @@ -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` (quartz, `nip01Core/diff/`): `diffFrom(older)` returns its own diff + class built from its own parsed objects, e.g. `ContactListDiff(follows: + ListDiff)`, `MuteListDiff(publicMutes: ListDiff, privateItems)`, + `AdvertisedRelayListDiff(relays: ListDiff)`, `NutzapInfoDiff(mints: + ListDiff, relays, p2pkPubkey: ValueChange)`, `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` (items matched by an identity key; a matched pair with different details + is a change, not a removal plus an addition), `ValueChange`, 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 84f2e38472..00a7fba7f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -50,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 @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index e654cc89b3..a76ebb2365 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -38,6 +38,7 @@ 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.quartz.concord.cord02Community.ConcordCommunityListEvent @@ -159,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" @@ -582,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) @@ -808,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) @@ -1064,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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index fa0a05f6a2..4d5e3c0fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 @@ -1889,6 +1891,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(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 signAndSendPrivately( template: EventTemplate, relayList: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index af1c120fdc..e2613fbe36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -25,6 +25,8 @@ 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 @@ -51,6 +53,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEve 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 @@ -1041,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>) = backupGuard.restore(saved) + + /** The open conflicts, as the three events storage needs to rebuild each one. */ + fun openBackupConflicts(): List> = 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) { if (localRelayServers.value != servers) { localRelayServers.update { servers } @@ -1059,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() } @@ -1109,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 @@ -1125,10 +1189,8 @@ class AccountSettings( clearNutzapInfo() return } - if (backupNutzapInfo?.id != newNutzapInfo.id) { - backupNutzapInfo = newNutzapInfo - saveAccountSettings() - } + backupNutzapInfo = newNutzapInfo + saveAccountSettings() } /** @@ -1138,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() @@ -1146,6 +1210,7 @@ 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() @@ -1184,80 +1249,72 @@ class AccountSettings( } 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() } @@ -1266,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() } @@ -1318,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() } @@ -1334,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() } @@ -1348,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() } @@ -1378,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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt index f0cf782cca..298fff1dff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -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, + ) { + 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt new file mode 100644 index 0000000000..b6ba88290e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorage.kt @@ -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, String>? = null + + fun encode(conflicts: List>): 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>): 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> { + if (stored.isNullOrBlank()) return emptyList() + val rows = + try { + JsonMapper.fromJson>>(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 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt index 14d3f6e18f..b402ab56bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -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(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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt index c935cd1b41..2089e28a20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 96cf0f2ff9..f596cb9f74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -87,9 +87,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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt index 8eb0154855..fb288259bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ProfilePictureFetcher.kt @@ -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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 84a2592bb8..35a8d02933 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index 1bab5f6660..4543f53330 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -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, + inbox: Set, + defaults: Set, +): Set = + 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 = + pickAccountDataRelays( + home = relayFlow(query).value, + inbox = query.account.nip65RelayList.inboxFlow.value, + defaults = Constants.eventFinderRelays, + ) + override fun updateFilter( keys: List, since: SincePerRelayMap?, ): List { val accountsPerRelay = mutableMapOf>() 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() } + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 83e8cafe21..2b2f9ea2cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -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]" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt index 89395aa9fb..fd4a2f866e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt @@ -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, @@ -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(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(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>() + 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt index 84ab47a779..a0cd3528d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt @@ -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 = _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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt index 0c0db2139c..c71c9b63c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index efe44bf917..efb7ee8db5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 03358ae3d7..dcfce38a90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -39,48 +39,21 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.asDrawable import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent -import com.vitorpamplona.amethyst.Amethyst +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter -import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomCacheRedirectInterceptor import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl import com.vitorpamplona.amethyst.commons.ui.components.forwardingPainter import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.ui.theme.onBackgroundColorFilter -import com.vitorpamplona.amethyst.ui.screen.AccountState -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf - -@OptIn(ExperimentalCoroutinesApi::class) -@Composable -private fun rememberLocalBlossomBridgeForProfilePics(): Boolean { - val sessionManager = - try { - Amethyst.instance.sessionManager - } catch (e: UninitializedPropertyAccessException) { - return false - } - val probe = Amethyst.instance.localBlossomCacheProbe - val flow = - remember { - combine( - sessionManager.accountContent.flatMapLatest { state -> - if (state is AccountState.LoggedIn) state.account.settings.useLocalBlossomCache else flowOf(false) - }, - probe.available, - ) { toggle, probeUp -> toggle && probeUp } - } - val state by flow.collectAsStateWithLifecycle(initialValue = false) - return state -} @Composable fun RobohashAsyncImage( @@ -120,21 +93,16 @@ fun RobohashFallbackAsyncImage( loadRobohash: Boolean, autoPlayGif: Boolean = true, ) { - val useBridge = rememberLocalBlossomBridgeForProfilePics() - val bridgedModel = - remember(model, robot, useBridge) { - bridgeProfilePictureUrl(model, useBridge, robot) - } - if (bridgedModel != null && loadProfilePicture && isAnimatedMediaUrl(bridgedModel)) { + if (model != null && loadProfilePicture && isAnimatedMediaUrl(model)) { GifProfilePicture( userHex = robot, - userPicture = bridgedModel, + userPicture = model, contentDescription = contentDescription, modifier = modifier, loadRobohash = loadRobohash, autoPlay = autoPlayGif, ) - } else if (bridgedModel != null && loadProfilePicture) { + } else if (model != null && loadProfilePicture) { val fallbackPainter = if (loadRobohash) { rememberVectorPainter( @@ -154,10 +122,10 @@ fun RobohashFallbackAsyncImage( // file://) would fail there. Route only remote http(s) pictures through the thumbnail // cache; hand local/content URIs to Coil's native fetchers, which load them directly. model = - if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) { - ProfilePictureUrl(bridgedModel) + if (model.startsWith("http://", ignoreCase = true) || model.startsWith("https://", ignoreCase = true)) { + ProfilePictureUrl(model) } else { - bridgedModel + model }, contentDescription = contentDescription, modifier = modifier, @@ -237,9 +205,25 @@ fun GifProfilePicture( ) } + val context = LocalContext.current + // Animated avatars skip ProfilePictureFetcher (its thumbnail cache would flatten them), so + // they carry the profile-picture marker themselves for the local Blossom cache bridge. + val model = + remember(userPicture) { + ImageRequest + .Builder(context) + .data(userPicture) + .httpHeaders( + NetworkHeaders + .Builder() + .set(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE) + .build(), + ).build() + } + Box(modifier = modifier) { SubcomposeAsyncImage( - model = userPicture, + model = model, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 9a96186328..2d20cfff06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -27,6 +27,7 @@ import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType +import com.vitorpamplona.amethyst.service.images.BlossomFetcher import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -93,7 +94,7 @@ object ShareHelper { ): Pair = withContext(Dispatchers.IO) { // Safely get snapshot and file - Amethyst.instance.diskCache.openSnapshot(imageUrl)?.use { snapshot -> + Amethyst.instance.diskCache.openSnapshot(BlossomFetcher.diskCacheKey(imageUrl))?.use { snapshot -> val file = snapshot.data.toFile() // Determine file extension and prepare sharable file diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index a27f3c9888..25c2422310 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.util.lerp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_image @@ -81,7 +80,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.commons.richtext.toCoilModel import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.model.MediaAspectRatioCache @@ -505,12 +503,6 @@ private fun RenderImageOrVideo( } val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url) - val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle() - val bridgedUrl = - remember(content.url, useLocalBlossomBridge) { - content.toCoilModel(useLocalBlossomBridge) - } - val modifier = if (ratio != null) { Modifier.aspectRatio(ratio) @@ -520,7 +512,7 @@ private fun RenderImageOrVideo( Box(modifier, contentAlignment = Alignment.Center) { VideoViewInner( - videoUri = bridgedUrl, + videoUri = content.url, mimeType = content.mimeType, aspectRatio = ratio, title = content.description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 0cfdd8f311..36fc70c585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -66,7 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter @@ -105,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import com.vitorpamplona.amethyst.commons.richtext.toCoilModel import com.vitorpamplona.amethyst.commons.service.image.placeholderModel import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog @@ -123,6 +121,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size75dp import com.vitorpamplona.amethyst.commons.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.service.images.BlossomFetcher import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -200,26 +199,20 @@ fun ZoomableContentView( sourceBounds = coordinates.boundsInWindow() } - val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle() - when (content) { is MediaUrlImage -> { val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url) - val bridgedUrl = - remember(content.url, useLocalBlossomBridge) { - content.toCoilModel(useLocalBlossomBridge) - } ContentWarningGate( isSensitive = content.contentWarning != null, reasons = setOfNotNull(content.contentWarning), - preloadUrls = listOf(bridgedUrl), + preloadUrls = listOf(content.url), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, contentScale), backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, ) { if (content.isAnimatedMedia()) { GifVideoView( - videoUri = bridgedUrl, + videoUri = content.url, contentDescription = content.description, dimensions = content.dim, blurhash = content.blurhash, @@ -254,10 +247,6 @@ fun ZoomableContentView( content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url) ?: fallbackRatio - val bridgedUrl = - remember(content.url, useLocalBlossomBridge) { - content.toCoilModel(useLocalBlossomBridge) - } ContentWarningGate( isSensitive = content.contentWarning != null, reasons = setOfNotNull(content.contentWarning), @@ -277,7 +266,7 @@ fun ZoomableContentView( contentAlignment = Alignment.Center, ) { VideoView( - videoUri = bridgedUrl, + videoUri = content.url, mimeType = content.mimeType, title = content.description, artworkUri = content.artworkUri, @@ -544,22 +533,17 @@ fun UrlImageView( } val context = LocalContext.current - val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle() - val bridgedUrl = - remember(content.url, useLocalBlossomBridge) { - content.toCoilModel(useLocalBlossomBridge) - } val imageModel = if (fullResolution) { - remember(bridgedUrl, context) { + remember(content.url, context) { ImageRequest .Builder(context) - .data(bridgedUrl) + .data(content.url) .size(Size.ORIGINAL) .build() } } else { - bridgedUrl + content.url } CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { @@ -1281,15 +1265,9 @@ private suspend fun shareLocalVideoFile( private fun verifyHash(content: MediaUrlContent): Boolean? { if (content.hash == null) return null - val keys = mutableListOf(content.url) - val bridged = content.toCoilModel(true) - if (bridged != content.url) keys.add(bridged) - - for (key in keys) { - Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot -> - val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream()) - return hashBytes.toHexKey() == content.hash - } + Amethyst.instance.diskCache.openSnapshot(BlossomFetcher.diskCacheKey(content.url))?.use { snapshot -> + val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream()) + return hashBytes.toHexKey() == content.hash } return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 395fda8738..cdbb1ea422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawe import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictReviewScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen @@ -680,6 +681,7 @@ fun BuildNavigation( composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { BackupConflictReviewScreen(it.slot, accountViewModel, nav) } composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index cbd90c2232..0376182a46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.Dao import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel @@ -275,32 +276,10 @@ class AccountViewModel( val feedStates = AccountFeedContentStates(account, viewModelScope) /** - * `true` when feed/note media (images and videos in `MediaUrlContent`) - * should be routed through the local Blossom cache. Requires the master - * toggle on, the probe up, AND the profile-pictures-only restriction - * to be off. + * `true` when the local Blossom cache is enabled and the probe sees it up. Only drives the + * "detected" chip in settings: the routing itself happens in LocalBlossomCacheRedirectInterceptor. */ - val useLocalBlossomBridge: StateFlow = - try { - combine( - account.settings.useLocalBlossomCache, - account.settings.localBlossomCacheProfilePicturesOnly, - Amethyst.instance.localBlossomCacheProbe.available, - ) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn( - viewModelScope, - SharingStarted.Eagerly, - false, - ) - } catch (e: UninitializedPropertyAccessException) { - MutableStateFlow(false) - } - - /** - * `true` when profile pictures should be routed through the local - * Blossom cache. Requires only the master toggle and the probe to be - * up; the profile-pictures-only restriction does not gate this flow. - */ - val useLocalBlossomBridgeForProfilePics: StateFlow = + val localBlossomCacheDetected: StateFlow = try { combine( account.settings.useLocalBlossomCache, @@ -1663,6 +1642,12 @@ class AccountViewModel( } } + /** + * Keeps another app's version of a backed-up list. Off the main thread: it waits for the + * backup guard's lock, which the backup collectors hold while diffing large lists. + */ + fun acceptExternalBackupVersion(conflict: ReplaceableBackupConflict) = viewModelScope.launch(Dispatchers.IO) { account.acceptExternalVersion(conflict) } + inline fun launchSigner(crossinline action: suspend () -> Unit) = viewModelScope.launch(Dispatchers.IO) { reportSignerErrors { action() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt new file mode 100644 index 0000000000..76bf31a345 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff + +/** + * Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user + * changed one of the account's backed-up lists (or the profile) in another app, and + * Amethyst asks to confirm before its backup moves on. They can't be dismissed. They stay + * until the user decides on the review screen they open ([BackupConflictReviewScreen]). + * + * The most recent conflict gets a lead card with a headline and a picture of the change + * (the follow list's bar, or removed/added counts); the rest are slim pills. They are the + * first item of the feed, so they scroll away with it instead of covering posts. + */ +@Composable +fun BackupConflictCards( + conflictMap: Map, + nav: INav, + onAccept: (ReplaceableBackupConflict) -> Unit, + modifier: Modifier = Modifier, +) { + if (conflictMap.isEmpty()) return + + val conflicts = remember(conflictMap) { conflictMap.values.sortedByDescending { it.cause.createdAt } } + val open = { conflict: ReplaceableBackupConflict -> nav.nav(Route.BackupConflictReview(conflict.slot)) } + + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val lead = conflicts.first() + LeadConflictCard(lead, onAccept = { onAccept(lead) }) { open(lead) } + conflicts.drop(1).forEach { conflict -> ConflictPill(conflict, onAccept = { onAccept(conflict) }) { open(conflict) } } + } +} + +/** A headline that says what happened, in the event's own terms. */ +@Composable +private fun headlineOf( + conflict: ReplaceableBackupConflict, + counts: ConflictCounts, +): String { + val diff = conflict.diff + val specific: String? = + when (diff) { + // Counted, not "shrank": a version that drops 3 follows and adds 100 still raises + // a conflict, and calling that a shrink contradicts the card's own bar. + is ContactListDiff -> plural(R.plurals.backup_card_follows_dropped, diff.follows.removed.size) + is MuteListDiff -> plural(R.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size) + is MetadataDiff -> plural(R.plurals.backup_card_profile_lost_fields, counts.removed) + is AdvertisedRelayListDiff -> plural(R.plurals.backup_card_nip65_lost, diff.relays.removed.size) + is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size) + is ChannelListDiff -> plural(R.plurals.backup_card_leave_chats, diff.channels.removed.size) + is CommunityListDiff -> plural(R.plurals.backup_card_leave_communities, diff.communities.removed.size) + is EphemeralChatListDiff -> plural(R.plurals.backup_card_leave_rooms, diff.rooms.removed.size) + is SimpleGroupListDiff -> plural(R.plurals.backup_card_leave_groups, diff.groups.removed.size) + is FavoriteAlgoFeedsListDiff -> plural(R.plurals.backup_card_feeds_removed, diff.feeds.removed.size) + is HashtagListDiff -> plural(R.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size) + is GeohashListDiff -> plural(R.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size) + is TrustProviderListDiff -> plural(R.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size) + is NutzapInfoDiff -> + when { + diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_removed) + diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_replaced) + else -> plural(R.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size) + } + is PaymentTargetsDiff -> plural(R.plurals.backup_card_payment_targets_removed, diff.targets.removed.size) + is Bolt12OfferListDiff -> plural(R.plurals.backup_card_offers_removed, diff.offers.removed.size) + is CashuWalletDiff -> encryptedHeadline(R.string.backup_card_wallet_emptied, R.string.backup_card_wallet_rewritten, diff.wallet) + is ConcordCommunityListDiff -> encryptedHeadline(R.string.backup_card_concord_emptied, R.string.backup_card_concord_rewritten, diff.communities) + is AppSpecificDataDiff -> stringRes(R.string.backup_card_app_settings) + else -> null + } + // A headline counting removed items reads wrong when only encrypted items were lost. + if (specific.isNullOrEmpty()) return stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) + // Headlines lead with the loss; what the other app added to the same list is said too, + // so a list that dropped 3 and gained 40 doesn't read as if it only shrank. The wording + // follows the headline's: following people, muting, joining chats, blocking relays. + val added = addedToHeadlinedList(diff, conflict.eventType) + if (added <= 0) return specific + val tail = + when { + diff is ContactListDiff -> R.plurals.backup_card_and_followed + diff is MuteListDiff -> R.plurals.backup_card_and_muted + diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined + conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked + else -> R.plurals.backup_card_and_added + } + return pluralStringResource(tail, added, specific, added) +} + +/** + * How many items were added to the list the headline counts removals from, or 0 when the + * headline isn't a count of list items (a key swap, an encrypted rewrite, a profile). + */ +private fun addedToHeadlinedList( + diff: EventDiff, + type: BackupEventType, +): Int = + when (diff) { + is ContactListDiff -> diff.follows.added.size + is MuteListDiff -> diff.publicMutes.added.size + is AdvertisedRelayListDiff -> diff.relays.added.size + is RelayListDiff -> if (relayListHasHeadline(type)) diff.relays.added.size else 0 + is ChannelListDiff -> diff.channels.added.size + is CommunityListDiff -> diff.communities.added.size + is EphemeralChatListDiff -> diff.rooms.added.size + is SimpleGroupListDiff -> diff.groups.added.size + is FavoriteAlgoFeedsListDiff -> diff.feeds.added.size + is HashtagListDiff -> diff.hashtags.added.size + is GeohashListDiff -> diff.geohashes.added.size + is NutzapInfoDiff -> if (diff.p2pkPubkey == null) diff.mints.added.size else 0 + is PaymentTargetsDiff -> diff.targets.added.size + is Bolt12OfferListDiff -> diff.offers.added.size + else -> 0 + } + +/** The relay lists [relayListHeadline] has a counted headline for. */ +private fun relayListHasHeadline(type: BackupEventType): Boolean = + when (type) { + BackupEventType.DM_RELAYS, BackupEventType.KEY_PACKAGE_RELAYS, BackupEventType.SEARCH_RELAYS, + BackupEventType.INDEXER_RELAYS, BackupEventType.RELAY_FEEDS, BackupEventType.PRIVATE_OUTBOX_RELAYS, + BackupEventType.TRUSTED_RELAYS, BackupEventType.BLOCKED_RELAYS, + -> true + else -> false + } + +/** The count's plural, or "" when nothing of that kind was removed. */ +@Composable +private fun plural( + id: Int, + count: Int, +): String = if (count > 0) pluralStringResource(id, count, count) else "" + +@Composable +private fun relayListHeadline( + type: BackupEventType, + removed: Int, +): String = + when (type) { + BackupEventType.DM_RELAYS -> plural(R.plurals.backup_card_dm_relays_lost, removed) + BackupEventType.KEY_PACKAGE_RELAYS -> plural(R.plurals.backup_card_key_package_relays_lost, removed) + BackupEventType.SEARCH_RELAYS -> plural(R.plurals.backup_card_search_relays_lost, removed) + BackupEventType.INDEXER_RELAYS -> plural(R.plurals.backup_card_indexer_relays_lost, removed) + BackupEventType.RELAY_FEEDS -> plural(R.plurals.backup_card_relay_feeds_lost, removed) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(R.plurals.backup_card_private_outbox_lost, removed) + BackupEventType.TRUSTED_RELAYS -> plural(R.plurals.backup_card_trusted_lost, removed) + BackupEventType.BLOCKED_RELAYS -> plural(R.plurals.backup_card_unblocked, removed) + else -> "" + } + +@Composable +private fun encryptedHeadline( + emptied: Int, + rewritten: Int, + change: ContentChange, +): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten) + +@Composable +private fun LeadConflictCard( + conflict: ReplaceableBackupConflict, + onAccept: () -> Unit, + onClick: () -> Unit, +) { + val tones = conflictTones() + val counts = remember(conflict) { countsOf(conflict.diff) } + Surface( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).clickable(onClick = onClick), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column( + Modifier.border(1.dp, tones.removed.copy(alpha = 0.35f), RoundedCornerShape(22.dp)).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.size(40.dp).clip(CircleShape).background(tones.removedContainer()), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.removed) + } + Column(Modifier.weight(1f)) { + Text(headlineOf(conflict, counts), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis) + Text( + stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + AcceptButton(onAccept) + } + val diff = conflict.diff + if (diff is ContactListDiff) { + val (saved, new) = conflict.followCounts + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Text(saved.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold) + SplitBar( + listOf( + BarSegment(saved - diff.follows.removed.size, tones.kept), + BarSegment(diff.follows.removed.size, tones.removed.copy(alpha = 0.6f)), + BarSegment(diff.follows.added.size, tones.added), + ), + Modifier.weight(1f), + height = 8.dp, + ) + Text(new.toString(), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = if (new < saved) tones.removed else tones.added) + } + } else { + CountChips(counts) + } + } + } +} + +@Composable +private fun CountChips(counts: ConflictCounts) { + val tones = conflictTones() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (counts.removed > 0) StatusTag(stringRes(R.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed) + if (counts.added > 0) StatusTag(stringRes(R.string.backup_conflict_count_added, counts.added.toString()), tones.added) + if (counts.changed > 0) StatusTag(stringRes(R.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed) + } +} + +@Composable +private fun ConflictPill( + conflict: ReplaceableBackupConflict, + onAccept: () -> Unit, + onClick: () -> Unit, +) { + val tones = conflictTones() + val counts = remember(conflict) { countsOf(conflict.diff) } + Surface( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 2.dp, + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(32.dp).clip(CircleShape).background(tones.changedContainer()), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.SyncProblem, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) + } + Text(headlineOf(conflict, counts), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + AcceptButton(onAccept) + } + } +} + +/** + * Confirms the change right from Home: the change was made on purpose in another app, so + * keeping it is the quick path. Reverting needs the review screen, one tap on the card away. + */ +@Composable +private fun AcceptButton(onAccept: () -> Unit) { + FilledTonalButton( + onClick = onAccept, + modifier = Modifier.height(36.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) { + Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt new file mode 100644 index 0000000000..ec711d8c7b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt @@ -0,0 +1,1183 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.BannerImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +private const val GRID_COLUMNS = 4 +private const val PEOPLE_PREVIEW = 6 + +/** UI state of the review screen that must survive the lazy list recomposing. */ +@Stable +class ReviewUiState { + var tab by mutableIntStateOf(0) + var query by mutableStateOf("") + var showAllPeople by mutableStateOf(false) +} + +/** + * The review list of a conflict, laid out for the kind of event that changed. Each layout + * leads with one picture of the change (a shrink bar, a shield, two profiles side by side, + * relay lanes, a key swap, group tiles) and lists the details below. All layouts contribute + * lazy items, so lists with hundreds of entries scroll smoothly. + */ +internal fun LazyListScope.eventDiffItems( + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + ui: ReviewUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (val diff = conflict.diff) { + is ContactListDiff -> followListItems(diff, conflict, ui, accountViewModel, nav) + is MuteListDiff -> muteListItems(diff, ui, accountViewModel, nav) + is MetadataDiff -> profileItems(diff, conflict, presentation, accountViewModel, nav) + is AdvertisedRelayListDiff -> nip65Items(conflict, nav) + is NutzapInfoDiff -> nutzapItems(diff, conflict, presentation, accountViewModel, nav) + is SimpleGroupListDiff -> groupItems(diff, conflict, accountViewModel, nav) + else -> if (!listDiffItems(conflict, accountViewModel, nav)) genericDiffItems(buildRows(presentation), accountViewModel, nav) + } +} + +private val Pad = Modifier.padding(horizontal = 16.dp) + +// --------------------------------------------------------------------------------------- +// Follow list: 523 → 120, a split bar, tabs and a searchable grid of faces. +// --------------------------------------------------------------------------------------- + +private fun LazyListScope.followListItems( + diff: ContactListDiff, + conflict: ReplaceableBackupConflict, + ui: ReviewUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val (saved, new) = conflict.followCounts + val dropped = diff.follows.removed.size + val gained = diff.follows.added.size + val edited = diff.follows.changed.size + + item(key = "follow-hero", contentType = "hero") { + val tones = conflictTones() + Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + HeroCounts(stringRes(R.string.backup_review_saved), saved, stringRes(R.string.backup_review_new), new) + SplitBar( + listOf( + BarSegment(saved - dropped, tones.kept), + BarSegment(dropped, tones.removed), + BarSegment(gained, tones.added), + ), + ) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + LegendItem(tones.kept, stringRes(R.string.backup_review_kept_count, (saved - dropped).toString())) + LegendItem(tones.removed, stringRes(R.string.backup_review_dropped_count, dropped.toString())) + LegendItem(tones.added, stringRes(R.string.backup_review_new_count, gained.toString())) + } + } + } + + item(key = "follow-tabs", contentType = "tabs") { + ReviewTabs( + labels = + listOf( + stringRes(R.string.backup_review_tab_dropped, dropped.toString()), + stringRes(R.string.backup_review_tab_new, gained.toString()), + stringRes(R.string.backup_review_tab_edited, edited.toString()), + ), + selected = ui.tab, + onSelect = { + // The search box only shows on long tabs; a query typed there must not keep + // filtering a short tab that has no box to clear it from. + ui.tab = it + ui.query = "" + }, + modifier = Pad.padding(top = 16.dp), + ) + } + + val (people, badge) = + when (ui.tab) { + 1 -> diff.follows.added.map { it.pubKey } to Badge.ADDED + 2 -> diff.follows.changed.map { it.after.pubKey } to Badge.CHANGED + else -> diff.follows.removed.map { it.pubKey } to Badge.REMOVED + } + + if (people.size > GRID_COLUMNS * 2) { + item(key = "follow-search", contentType = "search") { + PeopleSearch(ui.query, people.size, { ui.query = it }, Pad.padding(top = 12.dp)) + } + } + + val query = ui.query.trim() + val filtered = if (query.isEmpty()) people else people.filter { matchesPerson(it, query) } + + peopleGrid("follow-${ui.tab}", filtered, badge, accountViewModel, nav) + + if (filtered.isEmpty()) { + item(key = "follow-empty", contentType = "note") { + DetailText(stringRes(R.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp)) + } + } +} + +private fun matchesPerson( + pubKey: HexKey, + query: String, +): Boolean { + val user = LocalCache.getUserIfExists(pubKey) + val name = user?.toBestDisplayName() ?: pubKey + return name.contains(query, ignoreCase = true) || pubKey.startsWith(query, ignoreCase = true) +} + +private enum class Badge { REMOVED, ADDED, CHANGED } + +/** Faces in rows of [GRID_COLUMNS], each row a lazy item. */ +private fun LazyListScope.peopleGrid( + keyPrefix: String, + people: List, + badge: Badge, + accountViewModel: AccountViewModel, + nav: INav, +) { + items(people.chunked(GRID_COLUMNS), key = { keyPrefix + it.first() }, contentType = { "people-row" }) { row -> + Row(Pad.fillMaxWidth().padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { PersonCell(it, badge, accountViewModel, nav, Modifier.weight(1f)) } + repeat(GRID_COLUMNS - row.size) { Spacer(Modifier.weight(1f)) } + } + } +} + +@Composable +private fun PersonCell( + pubKey: HexKey, + badge: Badge, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier, +) { + val tones = conflictTones() + val (sign, color) = + when (badge) { + Badge.REMOVED -> "−" to tones.removed + Badge.ADDED -> "+" to tones.added + Badge.CHANGED -> "~" to tones.changed + } + LoadUser(pubKey, accountViewModel) { user -> + Column( + modifier.clip(RoundedCornerShape(12.dp)).clickable { nav.nav(Route.Profile(pubKey)) }.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box { + if (user != null) { + UserPicture(user, 56.dp, accountViewModel = accountViewModel, nav = nav) + } else { + Box(Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHighest)) + } + Box( + Modifier + .align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 2.dp) + .size(22.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(2.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center, + ) { + Text(sign, color = MaterialTheme.colorScheme.background, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp, lineHeight = 14.sp) + } + } + ProvideTextStyle(MaterialTheme.typography.labelMedium) { + if (user != null) { + UsernameDisplay(user, fontWeight = FontWeight.SemiBold, accountViewModel = accountViewModel) + } else { + Text(pubKey.toShortDisplay(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun ReviewTabs( + labels: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + labels.forEachIndexed { index, label -> + val isSelected = index == selected + Box( + Modifier + .weight(1f) + .height(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent) + .clickable { onSelect(index) }, + contentAlignment = Alignment.Center, + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText, + ) + } + } + } +} + +@Composable +private fun PeopleSearch( + query: String, + count: Int, + onChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onChange, + modifier = modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null) }, + placeholder = { Text(stringRes(R.string.backup_review_search_people, count.toString())) }, + ) +} + +// --------------------------------------------------------------------------------------- +// Mute list: a shield, per-kind tiles, faces, struck-through words, the private section. +// --------------------------------------------------------------------------------------- + +@OptIn(ExperimentalLayoutApi::class) +private fun LazyListScope.muteListItems( + diff: MuteListDiff, + ui: ReviewUiState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val removed = diff.publicMutes.removed + val added = diff.publicMutes.added + val people = removed.filterIsInstance().map { it.pubKey } + val words = removed.filterIsInstance().map { it.word } + val hashtags = removed.filterIsInstance().map { "#" + it.hashtag } + val threads = removed.filterIsInstance().map { it.eventId } + + item(key = "mute-hero", contentType = "hero") { + val tones = conflictTones() + TintedPanel(tones.removedContainer(), Pad.padding(top = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(symbol = MaterialSymbols.Shield, contentDescription = null, tint = tones.removed, modifier = Modifier.size(44.dp)) + Column { + Text( + stringRes(R.string.backup_review_unmuted, removed.size.toString()), + fontSize = 30.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold, + color = tones.removed, + ) + Text(stringRes(R.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + if (added.isNotEmpty()) { + Text(stringRes(R.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added) + } + } + } + } + } + + item(key = "mute-tiles", contentType = "tiles") { + val tones = conflictTones() + Row(Pad.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatTile("−${people.size}", stringRes(R.string.backup_entry_person), tones.removed, Modifier.weight(1f)) + StatTile("−${words.size}", stringRes(R.string.backup_entry_word), tones.removed, Modifier.weight(1f)) + StatTile("−${hashtags.size}", stringRes(R.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f)) + StatTile("−${threads.size}", stringRes(R.string.backup_entry_thread), tones.removed, Modifier.weight(1f)) + } + } + + if (people.isNotEmpty()) { + item(key = "mute-people", contentType = "facepile") { + Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionCaption(stringRes(R.string.backup_entry_person)) + FacePile(people, accountViewModel) { ui.showAllPeople = !ui.showAllPeople } + } + } + if (ui.showAllPeople) peopleGrid("mute-people-", people, Badge.REMOVED, accountViewModel, nav) + } + + if (words.isNotEmpty() || hashtags.isNotEmpty()) { + item(key = "mute-words", contentType = "pills") { + val tones = conflictTones() + Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionCaption(stringRes(R.string.backup_review_words_and_hashtags)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + (words.map { "\"$it\"" } + hashtags).forEach { StruckPill(it, tones.removed) } + } + } + } + } + + if (threads.isNotEmpty()) { + item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_thread), Pad.padding(top = 18.dp)) } + items(threads, key = { "mute-thread-$it" }, contentType = { "note" }) { id -> + LoadNote(id, accountViewModel) { note -> + if (note != null) { + NoteCompose(note, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav) + } else { + TextEntry(id.toShortDisplay(), null) + } + } + } + } + + if (diff.privateItems != ContentChange.NONE) { + item(key = "mute-private", contentType = "private") { PrivateItemsCard(diff.privateItems, Pad.padding(top = 18.dp)) } + } +} + +@Composable +private fun FacePile( + people: List, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + // Names and faces are observed, so people this device hasn't loaded yet resolve from relays. + val names = people.take(2).map { observedName(it, accountViewModel) } + val summary = + if (people.size > 2) { + stringRes(R.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString()) + } else { + names.joinToString(", ") + } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.height(48.dp)) { + people.take(PEOPLE_PREVIEW).forEachIndexed { index, pubKey -> + Box( + Modifier + .padding(start = (index * 32).dp) + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(3.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey, + model = observedPicture(pubKey, accountViewModel), + contentDescription = null, + modifier = Modifier.size(42.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + } + } + } + Text( + summary, + modifier = Modifier.padding(start = 12.dp).weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The user's metadata, observed: loads the [User] if needed, subscribes to their metadata + * on relays and recomposes when it arrives or changes in LocalCache. + */ +@Composable +internal fun observedUserInfo( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): UserMetadata? { + var user by remember(pubKey) { mutableStateOf(accountViewModel.getUserIfExists(pubKey)) } + if (user == null) { + LaunchedEffect(pubKey) { user = accountViewModel.checkGetOrCreateUser(pubKey) } + } + val loaded = user ?: return null + val info by observeUserInfo(loaded, accountViewModel) + return info?.info +} + +@Composable +internal fun observedName( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String = observedUserInfo(pubKey, accountViewModel)?.bestName() ?: pubKey.toShortDisplay() + +@Composable +internal fun observedPicture( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String? = observedUserInfo(pubKey, accountViewModel)?.profilePicture() + +@Composable +private fun StruckPill( + text: String, + color: Color, +) { + Text( + text, + modifier = + Modifier + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + color = color, + textDecoration = TextDecoration.LineThrough, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +internal fun PrivateItemsCard( + change: ContentChange, + modifier: Modifier = Modifier, +) { + val tones = conflictTones() + val (title, body) = + when (change) { + ContentChange.CLEARED -> R.string.backup_review_private_wiped_title to R.string.backup_review_private_wiped_body + ContentChange.ADDED -> R.string.backup_review_private_added_title to R.string.backup_review_private_added_body + else -> R.string.backup_review_private_changed_title to R.string.backup_review_private_changed_body + } + Row( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(tones.changedContainer()), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.Lock, contentDescription = null, tint = tones.changed) + } + Column(Modifier.weight(1f)) { + Text(stringRes(title), style = MaterialTheme.typography.titleSmall) + Text(stringRes(body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } +} + +// --------------------------------------------------------------------------------------- +// Profile: both versions side by side, then field-by-field inline diffs. +// --------------------------------------------------------------------------------------- + +private class ProfileField( + val labelRes: Int, + val change: ValueChange, + val isImage: Boolean = false, +) + +private fun profileFields(diff: MetadataDiff): List = + listOfNotNull( + diff.picture?.let { ProfileField(R.string.backup_profile_field_picture, it, isImage = true) }, + diff.banner?.let { ProfileField(R.string.backup_profile_field_banner, it, isImage = true) }, + diff.name?.let { ProfileField(R.string.backup_profile_field_name, it) }, + diff.displayName?.let { ProfileField(R.string.backup_profile_field_display_name, it) }, + diff.about?.let { ProfileField(R.string.backup_profile_field_about, it) }, + diff.nip05?.let { ProfileField(R.string.backup_profile_field_nip05, it) }, + diff.website?.let { ProfileField(R.string.backup_profile_field_website, it) }, + diff.lud16?.let { ProfileField(R.string.backup_profile_field_lud16, it) }, + diff.lud06?.let { ProfileField(R.string.backup_profile_field_lud06, it) }, + diff.clinkOffer?.let { ProfileField(R.string.backup_profile_field_clink_offer, it) }, + diff.pronouns?.let { ProfileField(R.string.backup_profile_field_pronouns, it) }, + diff.bot?.let { ProfileField(R.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) }, + diff.birthday?.let { + ProfileField( + R.string.backup_profile_field_birthday, + ValueChange( + it.before?.let { b -> listOfNotNull(b.year, b.month, b.day).joinToString("-") }, + it.after?.let { a -> listOfNotNull(a.year, a.month, a.day).joinToString("-") }, + ), + ) + }, + ) + +private fun LazyListScope.profileItems( + diff: MetadataDiff, + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + accountViewModel: AccountViewModel, + nav: INav, +) { + val saved = (conflict.saved as? MetadataEvent)?.contactMetaData() ?: UserMetadata() + val incoming = (conflict.incoming as? MetadataEvent)?.contactMetaData() ?: UserMetadata() + val pubKey = conflict.incoming.pubKey + + item(key = "profile-cards", contentType = "profile-cards") { + val tones = conflictTones() + Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MiniProfileCard(saved, pubKey, stringRes(R.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f)) + MiniProfileCard(incoming, pubKey, stringRes(R.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f)) + } + } + + val fields = profileFields(diff) + if (fields.isNotEmpty()) { + item(key = "profile-fields", contentType = "profile-fields") { + Column( + Pad + .padding(top = 14.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + fields.forEach { FieldDiffRow(it, accountViewModel) } + } + } + } + // Linked identities and fields this app doesn't model use the generic sections. + genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_profile_field)), accountViewModel, nav) +} + +@Composable +private fun MiniProfileCard( + meta: UserMetadata, + pubKey: HexKey, + label: String, + borderColor: Color, + accountViewModel: AccountViewModel, + modifier: Modifier, +) { + Column( + modifier + .clip(RoundedCornerShape(20.dp)) + .border(1.dp, borderColor, RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + BannerImage(meta.banner, Modifier.fillMaxWidth().height(64.dp), accountViewModel) + Column(Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Box( + Modifier + .offset(y = (-26).dp) + .size(52.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(3.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey, + model = meta.picture, + contentDescription = null, + modifier = Modifier.size(46.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + } + Column(Modifier.offset(y = (-20).dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(meta.bestName() ?: pubKey.toShortDisplay(), style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + meta.nip05?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis) } + meta.about?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 3, overflow = TextOverflow.Ellipsis) } + StatusTag(label, MaterialTheme.colorScheme.placeholderText, Modifier.padding(top = 6.dp)) + } + } + } +} + +@Composable +private fun FieldDiffRow( + field: ProfileField, + accountViewModel: AccountViewModel, +) { + val tones = conflictTones() + val before = field.change.before + val after = field.change.after + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringRes(field.labelRes), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText) + if (field.isImage) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ImageThumb(before, tones.removed, accountViewModel) + Text("→", color = MaterialTheme.colorScheme.placeholderText) + ImageThumb(after, tones.added, accountViewModel) + } + } else { + FlowRowDiff(before, after) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FlowRowDiff( + before: String?, + after: String?, +) { + val tones = conflictTones() + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) { + if (before != null) DiffChip(clip(before), tones.removed, struck = true) + when { + after == null -> StatusTag(stringRes(R.string.backup_review_gone), tones.removed) + before == null -> { + DiffChip(clip(after), tones.added, struck = false) + StatusTag(stringRes(R.string.backup_review_new_tag), tones.added) + } + else -> { + Text("→", color = MaterialTheme.colorScheme.placeholderText) + DiffChip(clip(after), tones.added, struck = false) + } + } + } +} + +@Composable +private fun DiffChip( + text: String, + color: Color, + struck: Boolean, +) { + Text( + text, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 8.dp, vertical = 2.dp), + color = color, + textDecoration = if (struck) TextDecoration.LineThrough else null, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun ImageThumb( + url: String?, + color: Color, + accountViewModel: AccountViewModel, +) { + val shape = RoundedCornerShape(10.dp) + Box( + Modifier + .size(72.dp) + .clip(shape) + .border(2.dp, color.copy(alpha = 0.6f), shape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + if (url == null) { + Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + } else { + MyAsyncImage( + imageUrl = url, + contentDescription = null, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.size(72.dp), + loadedImageModifier = Modifier.size(72.dp), + accountViewModel = accountViewModel, + onLoadingBackground = null, + onError = { Text(clip(url), style = MaterialTheme.typography.labelSmall, maxLines = 3) }, + ) + } + } +} + +// --------------------------------------------------------------------------------------- +// NIP-65: reach numbers, then outbox and inbox lanes with every relay's fate. +// --------------------------------------------------------------------------------------- + +private enum class LaneState { KEPT, DROPPED, ADDED, DEMOTED } + +private class LaneRow( + val url: String, + val state: LaneState, +) + +private fun lane( + saved: Map, + new: Map, + uses: (AdvertisedRelayType) -> Boolean, +): List { + val rows = mutableListOf() + saved.forEach { (url, type) -> + if (!uses(type)) return@forEach + val now = new[url] + rows.add( + LaneRow( + url, + when { + now == null -> LaneState.DROPPED + uses(now) -> LaneState.KEPT + else -> LaneState.DEMOTED + }, + ), + ) + } + new.forEach { (url, type) -> + if (uses(type) && saved[url]?.let(uses) != true) rows.add(LaneRow(url, LaneState.ADDED)) + } + return rows.sortedBy { it.state.ordinal } +} + +private fun LazyListScope.nip65Items( + conflict: ReplaceableBackupConflict, + nav: INav, +) { + val saved = (conflict.saved as? AdvertisedRelayListEvent)?.relays()?.associate { it.relayUrl.url to it.type } ?: emptyMap() + val new = (conflict.incoming as? AdvertisedRelayListEvent)?.relays()?.associate { it.relayUrl.url to it.type } ?: emptyMap() + val writes = { t: AdvertisedRelayType -> t != AdvertisedRelayType.READ } + val reads = { t: AdvertisedRelayType -> t != AdvertisedRelayType.WRITE } + val outbox = lane(saved, new, writes) + val inbox = lane(saved, new, reads) + + laneItems("outbox", stringRes = R.string.backup_review_outbox, subRes = R.string.backup_review_outbox_explainer, rows = outbox, nav = nav) + laneItems("inbox", stringRes = R.string.backup_review_inbox, subRes = R.string.backup_review_inbox_explainer, rows = inbox, nav = nav) +} + +private fun LazyListScope.laneItems( + key: String, + stringRes: Int, + subRes: Int, + rows: List, + nav: INav, +) { + if (rows.isEmpty()) return + item(key = "lane-$key", contentType = "lane-header") { + Row(Pad.padding(top = 18.dp, bottom = 6.dp).fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + Text(stringRes(stringRes), style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + Text(stringRes(subRes), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + } + } + items(rows, key = { "lane-$key-${it.url}" }, contentType = { "lane-row" }) { LaneRelayRow(it, nav) } +} + +@Composable +private fun LaneRelayRow( + row: LaneRow, + nav: INav, +) { + val tones = conflictTones() + val (color, tag) = + when (row.state) { + LaneState.KEPT -> tones.kept to null + LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to R.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to R.string.backup_review_demoted_tag + } + Row( + Pad + .padding(vertical = 3.dp) + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { nav.nav(Route.RelayInfo(row.url)) } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .then(if (row.state == LaneState.DROPPED) Modifier.border(2.dp, color, CircleShape) else Modifier.background(color)), + ) + Text( + row.url.removePrefix("wss://").removeSuffix("/"), + modifier = Modifier.weight(1f), + color = if (row.state == LaneState.KEPT) MaterialTheme.colorScheme.onSurface else color, + textDecoration = if (row.state == LaneState.DROPPED) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + tag?.let { StatusTag(stringRes(it), color) } + } +} + +// --------------------------------------------------------------------------------------- +// Nutzap info: the receiving key as a fingerprint swap, then mints. +// --------------------------------------------------------------------------------------- + +private fun LazyListScope.nutzapItems( + diff: NutzapInfoDiff, + conflict: ReplaceableBackupConflict, + presentation: DiffPresentation, + accountViewModel: AccountViewModel, + nav: INav, +) { + val key = diff.p2pkPubkey + if (key != null) { + item(key = "nutzap-key", contentType = "key-swap") { + val tones = conflictTones() + Column( + Pad + .padding(top = 8.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(symbol = MaterialSymbols.Key, contentDescription = null, tint = tones.changed) + Text(stringRes(if (key.after == null) R.string.backup_review_key_removed else R.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + KeyColumn(key.before, stringRes(R.string.backup_review_saved), tones.kept) + Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(horizontal = 18.dp)) + KeyColumn(key.after, stringRes(R.string.backup_review_new), tones.changed) + } + if (key.after != null) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(tones.changedContainer()) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + } + } + } + } + } + + // Matched the way the diff matches them, and once per mint: a list can repeat a mint, + // and a repeated row key crashes the LazyColumn. + val mintKey = { m: NutzapMintTag -> m.mintUrl.trimEnd('/').lowercase() } + val saved = (conflict.saved as? NutzapInfoEvent)?.mints().orEmpty().distinctBy(mintKey) + val removedUrls = + diff.mints.removed + .map(mintKey) + .toSet() + val changedUrls = diff.mints.changed.associateBy { mintKey(it.before) } + val mintRows = + saved.map { m -> + when (mintKey(m)) { + in removedUrls -> Triple(m.mintUrl, m.units.joinToString(), LaneState.DROPPED) + in changedUrls -> Triple(m.mintUrl, changedUrls.getValue(mintKey(m)).let { it.before.units.joinToString() + " → " + it.after.units.joinToString() }, LaneState.DEMOTED) + else -> Triple(m.mintUrl, m.units.joinToString(), LaneState.KEPT) + } + } + diff.mints.added.map { Triple(it.mintUrl, it.units.joinToString(), LaneState.ADDED) } + + if (mintRows.isNotEmpty()) { + item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) } + items(mintRows, key = { "mint-" + it.first }, contentType = { "mint" }) { (url, units, state) -> MintRow(url, units, state) } + } + genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_mint, R.string.backup_entry_nutzap_key)), accountViewModel, nav) +} + +@Composable +private fun KeyColumn( + hex: String?, + label: String, + color: Color, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (hex != null) { + KeyFingerprint(hex, color) + } else { + Box(Modifier.size(96.dp).clip(RoundedCornerShape(18.dp)).border(2.dp, color, RoundedCornerShape(18.dp)), contentAlignment = Alignment.Center) { + Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + } + } + StatusTag(label, color) + } +} + +@Composable +private fun MintRow( + url: String, + units: String, + state: LaneState, +) { + val tones = conflictTones() + val (color, tag) = + when (state) { + LaneState.KEPT -> MaterialTheme.colorScheme.onSurface to null + LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to R.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to R.string.backup_review_units_tag + } + Row( + Pad + .padding(vertical = 4.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.AccountBalanceWallet, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp)) + } + Column(Modifier.weight(1f)) { + Text( + url.removePrefix("https://").removeSuffix("/"), + color = color, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textDecoration = if (state == LaneState.DROPPED) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (units.isNotEmpty()) Text(units, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + } + tag?.let { StatusTag(stringRes(it), color) } + } +} + +// --------------------------------------------------------------------------------------- +// Groups: a tile per group, faded when you left it, badged when new or renamed. +// --------------------------------------------------------------------------------------- + +private class GroupTile( + val group: GroupTag, + val state: LaneState, + val oldName: String? = null, +) + +private fun LazyListScope.groupItems( + diff: SimpleGroupListDiff, + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Matched the way the diff matches them, and once per group: a repeated row key crashes + // the LazyColumn. + val key = { g: GroupTag -> g.groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(g.relayUrl)?.url ?: g.relayUrl) } + val removed = + diff.groups.removed + .map(key) + .toSet() + val changed = diff.groups.changed.associateBy { key(it.before) } + val saved = (conflict.saved as? SimpleGroupListEvent)?.publicGroups().orEmpty().distinctBy(key) + val tiles = + saved.map { g -> + when (key(g)) { + in removed -> GroupTile(g, LaneState.DROPPED) + in changed -> changed.getValue(key(g)).let { GroupTile(it.after, LaneState.DEMOTED, it.before.name) } + else -> GroupTile(g, LaneState.KEPT) + } + } + diff.groups.added.map { GroupTile(it, LaneState.ADDED) } + + item(key = "group-stats", contentType = "tiles") { + val tones = conflictTones() + Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatTile( + diff.groups.removed.size + .toString(), + stringRes(R.string.backup_review_left), + tones.removed, + Modifier.weight(1f), + ) + StatTile( + diff.groups.added.size + .toString(), + stringRes(R.string.backup_review_joined), + tones.added, + Modifier.weight(1f), + ) + StatTile( + diff.groups.changed.size + .toString(), + stringRes(R.string.backup_review_renamed), + tones.changed, + Modifier.weight(1f), + ) + } + } + items(tiles.chunked(2), key = { "group-" + key(it.first().group) }, contentType = { "group-row" }) { row -> + Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEach { GroupTileCard(it, accountViewModel, nav, Modifier.weight(1f)) } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } +} + +@Composable +private fun GroupTileCard( + tile: GroupTile, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier, +) { + val relay = RelayUrlNormalizer.normalizeOrNull(tile.group.relayUrl) + if (relay == null) { + GroupTileContent(tile, tile.group.name ?: tile.group.groupId, null, accountViewModel, modifier, null) + return + } + LoadRelayGroupChannel(GroupId(tile.group.groupId, relay), accountViewModel) { channel -> + // Subscribes to the group's relay-signed metadata (even when not joined) and recomposes + // when its name or picture arrives. + val state by observeChannel(channel, accountViewModel) + val current = (state?.channel as? RelayGroupChannel) ?: channel + val name = current.event?.name() ?: tile.group.name ?: tile.group.groupId + GroupTileContent(tile, name, current.profilePicture(), accountViewModel, modifier) { nav.nav(routeFor(current)) } + } +} + +@Composable +private fun GroupTileContent( + tile: GroupTile, + name: String, + picture: String?, + accountViewModel: AccountViewModel, + modifier: Modifier, + onClick: (() -> Unit)?, +) { + val tones = conflictTones() + Box( + modifier + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(14.dp), + ) { + Column( + Modifier.alpha(if (tile.state == LaneState.DROPPED) 0.45f else 1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + RobohashFallbackAsyncImage( + robot = tile.group.groupId + "@" + tile.group.relayUrl, + model = picture, + contentDescription = null, + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + Column { + Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + tile.oldName?.let { stringRes(R.string.backup_review_was_named, it) } ?: tile.group.relayUrl + .removePrefix("wss://") + .removeSuffix("/"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + when (tile.state) { + LaneState.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd)) + LaneState.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd)) + LaneState.DEMOTED -> StatusTag(stringRes(R.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd)) + LaneState.KEPT -> {} + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt new file mode 100644 index 0000000000..4e939c120a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -0,0 +1,937 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff +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.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.channels +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent + +/** + * Per-event layouts for the account's other lists: each relay list framed by what it is + * for (who can reach you, what gets unblocked…), joined spaces as tiles, followed topics + * as a pill cloud, trust providers as a per-service table, payment targets and offers as + * cards, and the encrypted-only events as a single explained panel. Returns false when + * [conflict] has no layout here, so the caller falls back to the generic one. + */ +internal fun LazyListScope.listDiffItems( + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +): Boolean { + when (val diff = conflict.diff) { + is RelayListDiff -> relayListItems(diff, conflict, nav) + is ChannelListDiff -> publicChatItems(diff, conflict, accountViewModel, nav) + is CommunityListDiff -> communityItems(diff, conflict, accountViewModel, nav) + is EphemeralChatListDiff -> ephemeralRoomItems(diff, conflict, nav) + is FavoriteAlgoFeedsListDiff -> favoriteFeedItems(diff, conflict, accountViewModel, nav) + is HashtagListDiff -> topicItems(diff.hashtags, (conflict.saved as? HashtagListEvent)?.publicHashtags().orEmpty(), diff.privateItems, isPlace = false, nav) + is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true, nav) + is TrustProviderListDiff -> trustProviderItems(diff, conflict, accountViewModel, nav) + is PaymentTargetsDiff -> paymentTargetItems(diff, conflict) + is Bolt12OfferListDiff -> offerItems(diff, conflict) + is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, R.string.backup_review_wallet_body) + is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, R.string.backup_review_concord_body) + else -> return false + } + return true +} + +private val Pad = Modifier.padding(horizontal = 16.dp) + +/** Fate of an item between the saved version and the new one. */ +internal enum class ItemFate { KEPT, DROPPED, ADDED, CHANGED } + +/** + * The saved items in their order, each marked kept or dropped (or changed), then the new + * ones: the full picture of a list, not just its differences. + */ +private fun fates( + saved: List, + diff: ListDiff, + key: (T) -> K, +): List> { + val removed = diff.removed.mapTo(HashSet(), key) + val changed = diff.changed.associateBy { key(it.after) } + val seen = HashSet() + val result = mutableListOf>() + saved.forEach { item -> + val k = key(item) + if (!seen.add(k)) return@forEach + when (k) { + in removed -> result.add(item to ItemFate.DROPPED) + in changed -> result.add(changed.getValue(k).after to ItemFate.CHANGED) + else -> result.add(item to ItemFate.KEPT) + } + } + diff.added.forEach { result.add(it to ItemFate.ADDED) } + return result +} + +@Composable +private fun fateColor(fate: ItemFate): Color { + val tones = conflictTones() + return when (fate) { + ItemFate.KEPT -> MaterialTheme.colorScheme.onSurface + ItemFate.DROPPED -> tones.removed + ItemFate.ADDED -> tones.added + ItemFate.CHANGED -> tones.changed + } +} + +private fun LazyListScope.countTiles( + items: List>, + removedCaption: Int, + addedCaption: Int, +) { + item(key = "count-tiles", contentType = "tiles") { + val tones = conflictTones() + Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatTile(items.count { it.second == ItemFate.DROPPED }.toString(), stringRes(removedCaption), tones.removed, Modifier.weight(1f)) + StatTile(items.count { it.second == ItemFate.ADDED }.toString(), stringRes(addedCaption), tones.added, Modifier.weight(1f)) + StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(R.string.backup_review_kept), tones.kept, Modifier.weight(1f)) + } + } +} + +private fun LazyListScope.privateItemsCard(change: ContentChange) { + if (change == ContentChange.NONE) return + item(key = "private-items", contentType = "private") { PrivateItemsCard(change, Pad.padding(top = 18.dp)) } +} + +// --------------------------------------------------------------------------------------- +// Relay lists, framed by what each one is for. +// --------------------------------------------------------------------------------------- + +/** How a relay list's rows are presented: its icon and what a kept or dropped relay is called. */ +private class RelayFraming( + val icon: MaterialSymbol, + val keptTag: Int, + val droppedTag: Int = R.string.backup_review_dropped_tag, +) + +private fun framingOf(type: BackupEventType): RelayFraming = + when (type) { + BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_receiving_tag) + BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_listed_tag) + BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_searching_tag) + BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag) + BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_listed_tag) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_storing_tag) + BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag) + else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag) + } + +private fun savedRelaysOf(event: Event): List = + when (event) { + is ChatMessageRelayListEvent -> event.relays() + is KeyPackageRelayListEvent -> event.relays() + is SearchRelayListEvent -> event.publicRelays() + is IndexerRelayListEvent -> event.publicRelays() + is RelayFeedsListEvent -> event.publicRelays() + is BlockedRelayListEvent -> event.publicRelays() + is TrustedRelayListEvent -> event.publicRelays() + is PrivateOutboxRelayListEvent -> event.publicRelays() + else -> emptyList() + } + +private fun LazyListScope.relayListItems( + diff: RelayListDiff, + conflict: ReplaceableBackupConflict, + nav: INav, +) { + val rows = fates(savedRelaysOf(conflict.saved), diff.relays) { it } + if (conflict.eventType == BackupEventType.BLOCKED_RELAYS) { + blockedRelayItems(rows, nav) + } else { + // No summary card: the rows themselves, tagged kept / dropped / new, are the change. + val framing = framingOf(conflict.eventType) + items(rows, key = { "relay-" + it.first.url }, contentType = { "relay-row" }) { (url, fate) -> + RelayFateRow(url, fate, framing, nav) + } + } + privateItemsCard(diff.privateRelays) +} + +@Composable +private fun RelayFateRow( + url: NormalizedRelayUrl, + fate: ItemFate, + framing: RelayFraming, + nav: INav, +) { + val color = fateColor(fate) + val tag = + when (fate) { + ItemFate.KEPT -> framing.keptTag + ItemFate.DROPPED -> framing.droppedTag + ItemFate.ADDED -> R.string.backup_review_new_tag + ItemFate.CHANGED -> R.string.backup_review_demoted_tag + } + Row( + Pad + .padding(top = 6.dp) + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { nav.nav(Route.RelayInfo(url.url)) } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(symbol = framing.icon, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp)) + Text( + url.url.removePrefix("wss://").removeSuffix("/"), + modifier = Modifier.weight(1f), + color = color, + textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + StatusTag(stringRes(tag), if (fate == ItemFate.KEPT) MaterialTheme.colorScheme.placeholderText else color) + } +} + +@OptIn(ExperimentalLayoutApi::class) +private fun LazyListScope.blockedRelayItems( + rows: List>, + nav: INav, +) { + val unblocked = rows.filter { it.second == ItemFate.DROPPED }.map { it.first } + val stillBlocked = rows.filter { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.map { it.first } + val newlyBlocked = rows.filter { it.second == ItemFate.ADDED }.map { it.first } + + item(key = "blocked-hero", contentType = "hero") { + val tones = conflictTones() + TintedPanel(tones.changedContainer(), Pad.padding(top = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(44.dp)) + Column { + Text(stringRes(R.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed) + Text(stringRes(R.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } + } + } + if (unblocked.isNotEmpty()) { + item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) } + items(unblocked, key = { "unblocked-" + it.url }, contentType = { "relay-row" }) { url -> + val tones = conflictTones() + Row( + Pad + .padding(top = 6.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, tones.changed.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { nav.nav(Route.RelayInfo(url.url)) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(36.dp).clip(CircleShape).border(2.dp, tones.changed, CircleShape), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) + } + Text(url.url.removePrefix("wss://").removeSuffix("/"), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + StatusTag(stringRes(R.string.backup_review_allowed_tag), tones.changed) + } + } + } + if (newlyBlocked.isNotEmpty()) { + item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) } + items(newlyBlocked, key = { "newly-blocked-" + it.url }, contentType = { "relay-row" }) { url -> + RelayFateRow(url, ItemFate.ADDED, framingOf(BackupEventType.BLOCKED_RELAYS), nav) + } + } + if (stillBlocked.isNotEmpty()) { + item(key = "still-blocked", contentType = "chips") { + Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionCaption(stringRes(R.string.backup_review_still_blocked)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + stillBlocked.forEach { url -> + Row( + Modifier + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { nav.nav(Route.RelayInfo(url.url)) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(symbol = MaterialSymbols.Lock, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(16.dp)) + Text(url.url.removePrefix("wss://").removeSuffix("/"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } + } + } + } + } +} + +// --------------------------------------------------------------------------------------- +// Joined spaces: public chats, communities, ephemeral rooms and favorite feeds as tiles. +// --------------------------------------------------------------------------------------- + +/** One space tile: its picture, name and subtitle are loaded by [content]. */ +private fun LazyListScope.spaceGrid( + keyPrefix: String, + items: List>, + key: (T) -> String, + content: @Composable (T, ItemFate, Modifier) -> Unit, +) { + countTiles(items, R.string.backup_review_left, R.string.backup_review_joined) + items(items.chunked(2), key = { keyPrefix + key(it.first().first) }, contentType = { "space-row" }) { row -> + Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEach { (item, fate) -> content(item, fate, Modifier.weight(1f)) } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } +} + +@Composable +private fun SpaceTile( + name: String, + subtitle: String, + robot: String, + picture: String?, + fate: ItemFate, + accountViewModel: AccountViewModel, + modifier: Modifier, + onClick: (() -> Unit)?, +) { + val tones = conflictTones() + Box( + modifier + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(14.dp), + ) { + Column(Modifier.alpha(if (fate == ItemFate.DROPPED) 0.45f else 1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + RobohashFallbackAsyncImage( + robot = robot, + model = picture, + contentDescription = null, + modifier = Modifier.size(48.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + Column { + Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + val tag = + when (fate) { + ItemFate.DROPPED -> R.string.backup_review_left_tag to tones.removed + ItemFate.ADDED -> R.string.backup_review_new_tag to tones.added + ItemFate.CHANGED -> R.string.backup_review_moved_tag to tones.changed + ItemFate.KEPT -> null + } + tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) } + } +} + +private fun LazyListScope.publicChatItems( + diff: ChannelListDiff, + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +) { + val saved = (conflict.saved as? ChannelListEvent)?.tags?.channels().orEmpty() + spaceGrid("chat-", fates(saved, diff.channels) { it.eventId }, { it.eventId }) { tag, fate, modifier -> + LoadPublicChatChannel(tag.eventId, accountViewModel) { channel -> + // Subscribes to the channel's metadata on relays and recomposes when it arrives. + val state by observeChannel(channel, accountViewModel) + val current = state?.channel ?: channel + SpaceTile( + name = current.toBestDisplayName(), + subtitle = stringRes(R.string.backup_type_public_chats), + robot = tag.eventId, + picture = channel.profilePicture(), + fate = fate, + accountViewModel = accountViewModel, + modifier = modifier, + onClick = { nav.nav(routeFor(channel)) }, + ) + } + } + privateItemsCard(diff.privateItems) +} + +private fun LazyListScope.communityItems( + diff: CommunityListDiff, + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +) { + val saved = (conflict.saved as? CommunityListEvent)?.publicCommunities().orEmpty() + spaceGrid("community-", fates(saved, diff.communities) { it.address }, { it.address.toValue() }) { tag, fate, modifier -> + AddressableSpaceTile(tag.address, fate, accountViewModel, modifier) { nav.nav(Route.Community(tag.address)) } + } + privateItemsCard(diff.privateItems) +} + +private fun LazyListScope.favoriteFeedItems( + diff: FavoriteAlgoFeedsListDiff, + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +) { + val saved = (conflict.saved as? FavoriteAlgoFeedsListEvent)?.publicFavoriteAlgoFeeds().orEmpty() + spaceGrid("feed-", fates(saved, diff.feeds) { it.address }, { it.address.toValue() }) { bookmark, fate, modifier -> + AddressableSpaceTile(bookmark.address, fate, accountViewModel, modifier) { + nav.nav { accountViewModel.getAddressableNoteIfExists(bookmark.address)?.let { routeFor(it, accountViewModel.account) } } + } + } + privateItemsCard(diff.privateItems) +} + +/** A community or feed tile: loads the definition event from relays for its name and picture. */ +@Composable +private fun AddressableSpaceTile( + address: Address, + fate: ItemFate, + accountViewModel: AccountViewModel, + modifier: Modifier, + onClick: () -> Unit, +) { + LoadAddressableNote(address, accountViewModel) { note -> + val event = + if (note != null) { + val state by observeNote(note, accountViewModel) + state.note.event + } else { + null + } + val (name, picture) = + when (event) { + is CommunityDefinitionEvent -> event.name() to event.image()?.imageUrl + is AppDefinitionEvent -> event.appMetaData().let { (it?.name ?: it?.displayName) to (it?.picture ?: it?.image) } + else -> null to null + } + SpaceTile( + name = name ?: address.dTag.ifBlank { address.toValue().toShortDisplay() }, + subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) R.string.backup_review_feed else R.string.backup_type_communities), + robot = address.toValue(), + picture = picture, + fate = fate, + accountViewModel = accountViewModel, + modifier = modifier, + onClick = onClick, + ) + } +} + +private fun LazyListScope.ephemeralRoomItems( + diff: EphemeralChatListDiff, + conflict: ReplaceableBackupConflict, + nav: INav, +) { + val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty() + val rooms = fates(saved, diff.rooms) { it } + countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined) + items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) } + privateItemsCard(diff.privateItems) +} + +@Composable +private fun RoomRow( + room: RoomId, + fate: ItemFate, + nav: INav, +) { + val color = fateColor(fate) + Row( + Pad + .padding(top = 8.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { nav.nav(routeFor(room)) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(color.copy(alpha = 0.14f)), contentAlignment = Alignment.Center) { + Text("#", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, color = color) + } + Column(Modifier.weight(1f).alpha(if (fate == ItemFate.DROPPED) 0.55f else 1f)) { + Text(room.id, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + room.relayUrl.url + .removePrefix("wss://") + .removeSuffix("/"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + when (fate) { + ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color) + ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color) + else -> {} + } + } +} + +// --------------------------------------------------------------------------------------- +// Followed hashtags and places: a pill cloud of what stays, goes and arrives. +// --------------------------------------------------------------------------------------- + +@OptIn(ExperimentalLayoutApi::class) +private fun LazyListScope.topicItems( + diff: ListDiff, + saved: List, + privateItems: ContentChange, + isPlace: Boolean, + nav: INav, +) { + val topics = fates(saved, diff) { it.lowercase() } + countTiles(topics, if (isPlace) R.string.backup_review_places_gone else R.string.backup_review_topics_gone, R.string.backup_review_new_count_caption) + item(key = "topic-cloud", contentType = "cloud") { + val tones = conflictTones() + Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionCaption(stringRes(if (isPlace) R.string.backup_review_places_you_follow else R.string.backup_review_hashtags_you_follow)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) { + topics.forEach { (topic, fate) -> + val color = fateColor(fate) + val shape = RoundedCornerShape(16.dp) + Row( + Modifier + .clip(shape) + .background( + when (fate) { + ItemFate.KEPT -> MaterialTheme.colorScheme.surfaceContainer + else -> color.copy(alpha = 0.12f) + }, + ).then(if (fate == ItemFate.DROPPED) Modifier.border(1.dp, tones.removed.copy(alpha = 0.5f), shape) else Modifier) + .clickable { nav.nav(if (isPlace) Route.Geohash(topic) else Route.Hashtag(topic)) } + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isPlace) Icon(symbol = MaterialSymbols.LocationOn, contentDescription = null, tint = color, modifier = Modifier.size(16.dp)) + val decoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null + if (isPlace) { + // Like the rest of the app: the city the geohash resolves to next to + // its code, and just the code until (or unless) the name resolves. + LoadCityName(topic, onLoading = { TopicText("#$topic", color, decoration) }) { city -> + TopicText(if (city == topic) "#$topic" else "$city · #$topic", color, decoration) + } + } else { + TopicText("#$topic", color, decoration) + } + } + } + } + } + } + privateItemsCard(privateItems) +} + +@Composable +private fun TopicText( + text: String, + color: Color, + decoration: TextDecoration?, +) { + Text( + text, + color = color, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textDecoration = decoration, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +// --------------------------------------------------------------------------------------- +// Trust providers: one row per service, who provided it before and who does now. +// --------------------------------------------------------------------------------------- + +private class ServiceRow( + val service: String, + val before: HexKey?, + val after: HexKey?, +) + +private fun LazyListScope.trustProviderItems( + diff: TrustProviderListDiff, + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, +) { + // A service can have several providers, so each side is a set per service: providers on + // both sides are kept rows, and the ones only before or only after are paired up as swaps. + val before = (conflict.saved as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey }) + val after = (conflict.incoming as? TrustProviderListEvent)?.serviceProviders().orEmpty().groupBy({ it.service.type }, { it.pubkey }) + val services = (before.keys + after.keys).distinct() + val rows = + services.flatMap { service -> + val had = before[service].orEmpty().distinct() + val has = after[service].orEmpty().distinct() + val gone = had - has.toSet() + val new = has - had.toSet() + had.filter { it in has }.map { ServiceRow(service, it, it) } + + List(maxOf(gone.size, new.size)) { ServiceRow(service, gone.getOrNull(it), new.getOrNull(it)) } + } + val affected = + rows + .filter { it.before != it.after } + .map { it.service } + .distinct() + .size + + item(key = "trust-hero", contentType = "hero") { + val tones = conflictTones() + TintedPanel(MaterialTheme.colorScheme.surfaceContainer, Pad.padding(top = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(tones.changedContainer()), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed) + } + Column { + Text(stringRes(R.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + Text(stringRes(R.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } + } + } + item(key = "trust-header", contentType = "table-header") { + Row(Pad.padding(top = 16.dp, bottom = 4.dp).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(R.string.backup_review_service, R.string.backup_review_saved, R.string.backup_review_new).forEachIndexed { index, res -> + Text(stringRes(res).uppercase(), Modifier.weight(if (index == 0) 1.1f else 1f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText) + } + } + } + items(rows, key = { "service-" + it.service + "-" + it.before + "-" + it.after }, contentType = { "service-row" }) { row -> ServiceTableRow(row, accountViewModel, nav) } + item(key = "trust-note", contentType = "note") { + val tones = conflictTones() + Row( + Pad + .padding(top = 14.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(tones.changedContainer()) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + } + } + privateItemsCard(diff.privateItems) +} + +@Composable +private fun ServiceTableRow( + row: ServiceRow, + accountViewModel: AccountViewModel, + nav: INav, +) { + val tones = conflictTones() + val tint = + when { + row.before == row.after -> MaterialTheme.colorScheme.surfaceContainer + row.after == null -> tones.removedContainer() + row.before == null -> tones.addedContainer() + else -> tones.changedContainer() + } + Row( + Pad + .padding(top = 6.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(tint) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(serviceLabel(row.service), Modifier.weight(1.1f), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, maxLines = 2) + ProviderCell(row.before, struck = row.before != row.after, color = tones.removed, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f)) + ProviderCell(row.after, struck = false, color = if (row.before == row.after) MaterialTheme.colorScheme.onSurface else tones.added, accountViewModel = accountViewModel, nav = nav, modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun serviceLabel(type: String): String = + when (type) { + "rank" -> stringRes(R.string.backup_review_service_rank) + "followers" -> stringRes(R.string.backup_review_service_followers) + "t" -> stringRes(R.string.backup_review_service_topics) + else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() } + } + +@Composable +private fun ProviderCell( + pubKey: HexKey?, + struck: Boolean, + color: Color, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier, +) { + if (pubKey == null) { + Text("—", modifier, color = MaterialTheme.colorScheme.placeholderText) + return + } + LoadUser(pubKey, accountViewModel) { user -> + Row( + modifier.clip(RoundedCornerShape(8.dp)).clickable { nav.nav(Route.Profile(pubKey)) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey, + // Observed, so the picture appears when the provider's metadata arrives. + model = observedPicture(pubKey, accountViewModel), + contentDescription = null, + modifier = Modifier.size(22.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(color = if (struck) color else MaterialTheme.colorScheme.onSurface, textDecoration = if (struck) TextDecoration.LineThrough else null)) { + if (user != null) { + UsernameDisplay(user, fontWeight = FontWeight.Medium, textColor = if (struck) color else Color.Unspecified, accountViewModel = accountViewModel) + } else { + Text(pubKey.toShortDisplay(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +// --------------------------------------------------------------------------------------- +// Payment targets and BOLT12 offers: the ways people pay you, as cards. +// --------------------------------------------------------------------------------------- + +private fun LazyListScope.paymentTargetItems( + diff: PaymentTargetsDiff, + conflict: ReplaceableBackupConflict, +) { + val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty() + val targets = fates(saved, diff.targets) { it } + moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone) + items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) } +} + +private fun LazyListScope.offerItems( + diff: Bolt12OfferListDiff, + conflict: ReplaceableBackupConflict, +) { + val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty() + val offers = fates(saved, diff.offers) { it } + moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone) + items(offers, key = { "offer-" + it.first }, contentType = { "money-row" }) { (offer, fate) -> + MoneyRow(stringRes(R.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate) + } +} + +private fun LazyListScope.moneyHero( + dropped: Int, + headline: Int, +) { + item(key = "money-hero", contentType = "hero") { + val tones = conflictTones() + val color = if (dropped > 0) tones.removed else tones.added + TintedPanel(color.copy(alpha = 0.12f), Pad.padding(top = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(color.copy(alpha = 0.18f)), contentAlignment = Alignment.Center) { + Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = color) + } + Column { + Text(pluralStringResource(headline, dropped, dropped), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color) + Text(stringRes(R.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + } + } + } +} + +@Composable +private fun PaymentTargetRow( + target: PaymentTarget, + fate: ItemFate, +) = MoneyRow(target.type.replaceFirstChar { it.uppercase() }, target.authority, fate) + +@Composable +private fun MoneyRow( + kind: String, + value: String, + fate: ItemFate, +) { + val color = fateColor(fate) + Row( + Pad + .padding(top = 8.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) { + Text(kind.take(1), fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.placeholderText) + } + Column(Modifier.weight(1f)) { + Text(kind, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = color, + textDecoration = if (fate == ItemFate.DROPPED) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + when (fate) { + ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color) + ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color) + else -> {} + } + } +} + +// --------------------------------------------------------------------------------------- +// Encrypted-only events (Cashu wallet, Concord list): one explained panel. +// --------------------------------------------------------------------------------------- + +private fun LazyListScope.encryptedOnlyItems( + icon: MaterialSymbol, + change: ContentChange, + body: Int, +) { + item(key = "encrypted-hero", contentType = "hero") { + val tones = conflictTones() + val (color, headline) = + when (change) { + ContentChange.CLEARED -> tones.removed to R.string.backup_review_encrypted_cleared + ContentChange.ADDED -> tones.added to R.string.backup_review_encrypted_added + else -> tones.changed to R.string.backup_review_encrypted_changed + } + Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + TintedPanel(color.copy(alpha = 0.12f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(symbol = icon, contentDescription = null, tint = color, modifier = Modifier.size(44.dp)) + Text(stringRes(headline), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color) + } + } + PrivateItemsCard(change) + Text(stringRes(body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt new file mode 100644 index 0000000000..6c7772ba36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -0,0 +1,485 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsDiff +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ItemChange +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange +import com.vitorpamplona.quartz.nip01Core.metadata.Birthday +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff + +private const val MAX_VALUE_LENGTH = 80 + +/** Items lost, gained and changed in a conflict, counting encrypted content as one item. */ +@Immutable +class ConflictCounts( + val removed: Int, + val added: Int, + val changed: Int, +) + +private fun ContentChange.asCounts() = + ConflictCounts( + removed = if (this == ContentChange.CLEARED) 1 else 0, + added = if (this == ContentChange.ADDED) 1 else 0, + changed = if (this == ContentChange.CHANGED) 1 else 0, + ) + +private operator fun ConflictCounts.plus(other: ConflictCounts) = ConflictCounts(removed + other.removed, added + other.added, changed + other.changed) + +private fun ListDiff<*>.asCounts() = ConflictCounts(removed.size, added.size, changed.size) + +private fun ValueChange<*>?.asCounts() = + when { + this == null -> ConflictCounts(0, 0, 0) + after == null -> ConflictCounts(1, 0, 0) + before == null -> ConflictCounts(0, 1, 0) + else -> ConflictCounts(0, 0, 1) + } + +/** + * The counts straight from a diff's own lists, without building display items: what the + * Home cards need, however large the conflict. + */ +fun countsOf(diff: EventDiff): ConflictCounts = + when (diff) { + is MetadataDiff -> + listOf(diff.name, diff.displayName, diff.picture, diff.banner, diff.website, diff.about, diff.pronouns, diff.nip05, diff.lud06, diff.lud16, diff.clinkOffer, diff.bot, diff.birthday) + .fold(diff.otherFields.asCounts() + diff.identityClaims.asCounts()) { acc, change -> acc + change.asCounts() } + is ContactListDiff -> diff.follows.asCounts() + is MuteListDiff -> diff.publicMutes.asCounts() + diff.privateItems.asCounts() + is AdvertisedRelayListDiff -> diff.relays.asCounts() + is RelayListDiff -> diff.relays.asCounts() + diff.privateRelays.asCounts() + is ChannelListDiff -> diff.channels.asCounts() + diff.privateItems.asCounts() + is CommunityListDiff -> diff.communities.asCounts() + diff.privateItems.asCounts() + is HashtagListDiff -> diff.hashtags.asCounts() + diff.privateItems.asCounts() + is GeohashListDiff -> diff.geohashes.asCounts() + diff.privateItems.asCounts() + is FavoriteAlgoFeedsListDiff -> diff.feeds.asCounts() + diff.privateItems.asCounts() + is EphemeralChatListDiff -> diff.rooms.asCounts() + diff.privateItems.asCounts() + is SimpleGroupListDiff -> diff.groups.asCounts() + diff.privateItems.asCounts() + is TrustProviderListDiff -> diff.providers.asCounts() + diff.privateItems.asCounts() + is NutzapInfoDiff -> diff.mints.asCounts() + diff.relays.asCounts() + diff.p2pkPubkey.asCounts() + is PaymentTargetsDiff -> diff.targets.asCounts() + is Bolt12OfferListDiff -> diff.offers.asCounts() + is CashuWalletDiff -> diff.wallet.asCounts() + is ConcordCommunityListDiff -> diff.communities.asCounts() + is AppSpecificDataDiff -> diff.data.asCounts() + else -> ConflictCounts(0, 0, 0) + } + +/** + * One entry of a conflict, typed by what it points to so the review screen can load it from + * relays and link to it. [detail] is extra text: a petname, a relay marker, before → after. + */ +@Immutable +sealed interface ReviewItem { + val detail: String? + + class Person( + val pubKey: HexKey, + override val detail: String? = null, + ) : ReviewItem + + class Thread( + val eventId: HexKey, + override val detail: String? = null, + ) : ReviewItem + + class PublicChat( + val channelId: HexKey, + override val detail: String? = null, + ) : ReviewItem + + class Addressable( + val address: Address, + override val detail: String? = null, + ) : ReviewItem + + class ChatRoom( + val roomId: RoomId, + override val detail: String? = null, + ) : ReviewItem + + class Relay( + val url: NormalizedRelayUrl, + override val detail: String? = null, + ) : ReviewItem + + class Text( + val text: String, + override val detail: String? = null, + ) : ReviewItem +} + +/** One labelled group of differences (people, relays, profile fields…) of a conflict. */ +@Immutable +class DiffGroup( + val label: Int, + val removed: List, + val added: List, + val changed: List, +) { + fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty() +} + +/** Everything a conflict shows: its non-empty groups and the change to its encrypted content. */ +@Immutable +class DiffPresentation( + val groups: List, + val content: ContentChange, +) { + val removedCount = groups.sumOf { it.removed.size } + val addedCount = groups.sumOf { it.added.size } + val changedCount = groups.sumOf { it.changed.size } +} + +/** A group from a [ListDiff] of an event's parsed items. */ +private fun listGroup( + label: Int, + diff: ListDiff, + item: (T) -> ReviewItem, + change: (ItemChange) -> ReviewItem, +) = DiffGroup(label, diff.removed.map(item), diff.added.map(item), diff.changed.map(change)) + +/** A group whose items can't change, only be added or removed (hashtags, plain relays…). */ +private fun listGroup( + label: Int, + diff: ListDiff, + item: (T) -> ReviewItem, +) = listGroup(label, diff, item) { item(it.after) } + +/** Keeps only the items of [T] from a list diff of a sealed family (e.g. one kind of [MuteTag]). */ +private inline fun ListDiff<*>.only() = + ListDiff( + removed.filterIsInstance(), + added.filterIsInstance(), + changed.filter { it.before is T }.map { ItemChange(it.before as T, it.after as T) }, + ) + +/** A group from single-valued fields: a field set only after is added, only before is removed. */ +private fun fieldGroup( + label: Int, + fields: List?>>, +): DiffGroup { + val removed = mutableListOf() + val added = mutableListOf() + val changed = mutableListOf() + fields.forEach { (name, change) -> + if (change == null) return@forEach + val before = change.before + val after = change.after + when { + before != null && after == null -> removed.add(ReviewItem.Text(name, clip(before))) + before == null && after != null -> added.add(ReviewItem.Text(name, clip(after))) + else -> changed.add(ReviewItem.Text(name, clip(before) + " → " + clip(after))) + } + } + return DiffGroup(label, removed, added, changed) +} + +private fun arrow( + before: String?, + after: String?, +) = (before ?: "∅") + " → " + (after ?: "∅") + +/** + * Each event's own diff as typed, labelled groups, built once per diff: it maps every item, + * which for a large list is too much work to redo on each recomposition. + */ +@Composable +fun rememberPresentation(diff: EventDiff): DiffPresentation { + val context = LocalContext.current + return remember(diff, context) { presentationOf(diff) { context.getString(it) } } +} + +/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */ +fun presentationOf( + diff: EventDiff, + str: (Int) -> String, +): DiffPresentation { + val (groups, content) = + when (diff) { + is MetadataDiff -> { + val birthday = diff.birthday?.let { ValueChange(it.before?.let(::birthdayText), it.after?.let(::birthdayText)) } + val bot = diff.bot?.let { ValueChange(it.before?.toString(), it.after?.toString()) } + listOf( + fieldGroup( + R.string.backup_entry_profile_field, + listOf( + str(R.string.backup_profile_field_name) to diff.name, + str(R.string.backup_profile_field_display_name) to diff.displayName, + str(R.string.backup_profile_field_about) to diff.about, + str(R.string.backup_profile_field_picture) to diff.picture, + str(R.string.backup_profile_field_banner) to diff.banner, + str(R.string.backup_profile_field_website) to diff.website, + str(R.string.backup_profile_field_nip05) to diff.nip05, + str(R.string.backup_profile_field_lud16) to diff.lud16, + str(R.string.backup_profile_field_lud06) to diff.lud06, + str(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, + str(R.string.backup_profile_field_pronouns) to diff.pronouns, + str(R.string.backup_profile_field_birthday) to birthday, + str(R.string.backup_profile_field_bot) to bot, + ), + ), + listGroup( + R.string.backup_entry_identity_claim, + diff.identityClaims, + { ReviewItem.Text(it.platformIdentity()) }, + { ReviewItem.Text(it.after.platformIdentity(), arrow(it.before.proof, it.after.proof)) }, + ), + listGroup( + R.string.backup_entry_other, + diff.otherFields, + { ReviewItem.Text(it.first, clip(it.second)) }, + { ReviewItem.Text(it.after.first, clip(it.before.second) + " → " + clip(it.after.second)) }, + ), + ) to ContentChange.NONE + } + is ContactListDiff -> + listOf( + listGroup( + R.string.backup_entry_person, + diff.follows, + { ReviewItem.Person(it.pubKey, it.petname) }, + { + ReviewItem.Person( + it.after.pubKey, + arrow( + listOfNotNull(it.before.petname, it.before.relayUri?.url).joinToString().ifEmpty { null }, + listOfNotNull(it.after.petname, it.after.relayUri?.url).joinToString().ifEmpty { null }, + ), + ) + }, + ), + ) to ContentChange.NONE + is MuteListDiff -> + listOf( + listGroup(R.string.backup_entry_person, diff.publicMutes.only(), { ReviewItem.Person(it.pubKey) }), + listGroup(R.string.backup_entry_word, diff.publicMutes.only(), { ReviewItem.Text("\"" + it.word + "\"") }), + listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only(), { ReviewItem.Text("#" + it.hashtag) }), + listGroup(R.string.backup_entry_thread, diff.publicMutes.only(), { ReviewItem.Thread(it.eventId) }), + ) to diff.privateItems + is AdvertisedRelayListDiff -> { + val types = + mapOf( + AdvertisedRelayType.BOTH to str(R.string.backup_conflict_relay_read_write), + AdvertisedRelayType.READ to str(R.string.backup_conflict_relay_read_only), + AdvertisedRelayType.WRITE to str(R.string.backup_conflict_relay_write_only), + ) + listOf( + listGroup( + R.string.backup_entry_relay, + diff.relays, + { ReviewItem.Relay(it.relayUrl, types[it.type]) }, + { ReviewItem.Relay(it.after.relayUrl, arrow(types[it.before.type], types[it.after.type])) }, + ), + ) to ContentChange.NONE + } + is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays + is ChannelListDiff -> + listOf( + listGroup( + R.string.backup_entry_public_chat, + diff.channels, + { ReviewItem.PublicChat(it.eventId) }, + { ReviewItem.PublicChat(it.after.eventId, arrow(it.before.relay?.url, it.after.relay?.url)) }, + ), + ) to diff.privateItems + is CommunityListDiff -> + listOf( + listGroup( + R.string.backup_entry_community, + diff.communities, + { ReviewItem.Addressable(it.address) }, + { ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) }, + ), + ) to diff.privateItems + is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems + is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems + is FavoriteAlgoFeedsListDiff -> + listOf( + listGroup( + R.string.backup_entry_algo_feed, + diff.feeds, + { ReviewItem.Addressable(it.address) }, + { ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) }, + ), + ) to diff.privateItems + is EphemeralChatListDiff -> listOf(listGroup(R.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems + is SimpleGroupListDiff -> + listOf( + listGroup( + R.string.backup_entry_relay_group, + diff.groups, + { ReviewItem.Text(it.name ?: it.groupId, it.relayUrl) }, + { ReviewItem.Text(it.after.name ?: it.after.groupId, arrow(it.before.name, it.after.name)) }, + ), + ) to diff.privateItems + is TrustProviderListDiff -> + listOf( + listGroup( + R.string.backup_entry_trust_provider, + diff.providers, + { ReviewItem.Person(it.pubkey, it.service.type) }, + { ReviewItem.Person(it.after.pubkey, it.after.service.type + ": " + arrow(it.before.relayUrl.url, it.after.relayUrl.url)) }, + ), + ) to diff.privateItems + is NutzapInfoDiff -> + listOf( + listGroup( + R.string.backup_entry_mint, + diff.mints, + { ReviewItem.Text(it.mintUrl, it.units.joinToString().ifEmpty { null }) }, + { ReviewItem.Text(it.after.mintUrl, arrow(it.before.units.joinToString(), it.after.units.joinToString())) }, + ), + listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }), + nutzapKeyGroup(diff.p2pkPubkey), + ) to ContentChange.NONE + is PaymentTargetsDiff -> + listOf( + listGroup(R.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }), + ) to ContentChange.NONE + is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE + is CashuWalletDiff -> emptyList() to diff.wallet + is ConcordCommunityListDiff -> emptyList() to diff.communities + is AppSpecificDataDiff -> emptyList() to diff.data + else -> emptyList() to ContentChange.NONE + } + return DiffPresentation(groups.filterNot { it.isEmpty() }, content) +} + +private fun nutzapKeyGroup(change: ValueChange?): DiffGroup { + val none = emptyList() + if (change == null) return DiffGroup(R.string.backup_entry_nutzap_key, none, none, none) + val before = change.before + val after = change.after + return when { + before != null && after == null -> DiffGroup(R.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none) + before == null && after != null -> DiffGroup(R.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none) + else -> DiffGroup(R.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after)))) + } +} + +private fun birthdayText(birthday: Birthday): String = listOfNotNull(birthday.year, birthday.month, birthday.day).joinToString("-") + +fun clip(text: String?): String { + if (text == null) return "" + val oneLine = text.replace('\n', ' ') + return if (oneLine.length > MAX_VALUE_LENGTH) oneLine.take(MAX_VALUE_LENGTH) + "…" else oneLine +} + +fun eventTypeName(type: BackupEventType): Int = + when (type) { + BackupEventType.PROFILE -> R.string.backup_type_profile + BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list + BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list + BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays + BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays + BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays + BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays + BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays + BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds + BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays + BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays + BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays + BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings + BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats + BackupEventType.COMMUNITIES -> R.string.backup_type_communities + BackupEventType.HASHTAGS -> R.string.backup_type_hashtags + BackupEventType.GEOHASHES -> R.string.backup_type_geohashes + BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds + BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats + BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups + BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities + BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers + BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet + BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info + BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets + BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers + BackupEventType.OTHER -> R.string.backup_type_other + } + +fun eventTypeExplainer(type: BackupEventType): Int = + when (type) { + BackupEventType.PROFILE -> R.string.backup_type_profile_explainer + BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list_explainer + BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list_explainer + BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays_explainer + BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays_explainer + BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays_explainer + BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays_explainer + BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays_explainer + BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds_explainer + BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays_explainer + BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays_explainer + BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays_explainer + BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings_explainer + BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats_explainer + BackupEventType.COMMUNITIES -> R.string.backup_type_communities_explainer + BackupEventType.HASHTAGS -> R.string.backup_type_hashtags_explainer + BackupEventType.GEOHASHES -> R.string.backup_type_geohashes_explainer + BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds_explainer + BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats_explainer + BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups_explainer + BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities_explainer + BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers_explainer + BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet_explainer + BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info_explainer + BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets_explainer + BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers_explainer + BackupEventType.OTHER -> R.string.backup_type_other_explainer + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt new file mode 100644 index 0000000000..79405eb3ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt @@ -0,0 +1,523 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff + +/** A row of the review list, flattened so hundreds of entries scroll lazily. */ +@Immutable +internal sealed interface ReviewRow { + class Section( + val titleRes: Int, + val count: Int, + val tone: Tone, + ) : ReviewRow + + class Note( + val textRes: Int, + ) : ReviewRow + + class Group( + val labelRes: Int, + ) : ReviewRow + + class Entry( + val item: ReviewItem, + ) : ReviewRow +} + +internal enum class Tone { REMOVED, ADDED, CHANGED } + +internal fun buildRows( + presentation: DiffPresentation, + skipGroups: Set = emptySet(), +): List { + val rows = mutableListOf() + + fun section( + titleRes: Int, + count: Int, + tone: Tone, + contentNote: Int?, + items: (DiffGroup) -> List, + ) { + if (count == 0 && contentNote == null) return + rows.add(ReviewRow.Section(titleRes, count, tone)) + contentNote?.let { rows.add(ReviewRow.Note(it)) } + presentation.groups.forEach { group -> + if (group.label in skipGroups) return@forEach + val entries = items(group) + if (entries.isNotEmpty()) { + rows.add(ReviewRow.Group(group.label)) + entries.forEach { rows.add(ReviewRow.Entry(it)) } + } + } + } + + val content = presentation.content + section(R.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, R.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed } + section(R.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, R.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added } + section(R.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, R.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed } + return rows +} + +/** + * Every entry of a backup conflict, removed / added / changed, in a lazy list: people, + * notes, communities, feeds, chats and relays are loaded from relays and open their own + * screens when tapped, so the user can investigate the change before deciding. Opened from + * the Home cards ([BackupConflictCards]); deciding resolves the conflict and removes its card. + * Leaving without deciding keeps the card and the frozen backup. + */ +@Composable +fun BackupConflictReviewScreen( + slot: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val settings = accountViewModel.account.settings + val conflicts by settings.backupConflicts.collectAsStateWithLifecycle() + val conflict = conflicts[slot] + + // The only way out after a decision: resolving (from here or elsewhere) removes the + // conflict, and this pops the screen exactly once. The buttons don't pop themselves, + // which would pop a second screen, and a stale conflict (replaced by a newer one) is + // simply shown in its place. + val popped = remember { mutableStateOf(false) } + if (conflict == null) { + LaunchedEffect(Unit) { + if (!popped.value) { + popped.value = true + nav.popBack() + } + } + return + } + + BackupConflictReview( + conflict = conflict, + accountViewModel = accountViewModel, + nav = nav, + // A read-only account can't sign the saved version again, so it can only keep the new one. + onRestore = if (accountViewModel.isWriteable()) ({ accountViewModel.launchSigner { accountViewModel.account.restoreBackupOver(conflict) } }) else null, + onKeepNew = { accountViewModel.acceptExternalBackupVersion(conflict) }, + ) +} + +@Composable +private fun BackupConflictReview( + conflict: ReplaceableBackupConflict, + accountViewModel: AccountViewModel, + nav: INav, + onRestore: (() -> Unit)?, + onKeepNew: () -> Unit, +) { + val presentation = rememberPresentation(conflict.diff) + val ui = remember(conflict.slot) { ReviewUiState() } + val (keepLabel, restoreLabel) = actionLabels(conflict) + + Scaffold( + topBar = { + TopBarWithBackButton(eventTitle(conflict), nav) + }, + bottomBar = { ReviewActions(keepLabel, restoreLabel, onRestore, onKeepNew) }, + ) { padding -> + val layoutDirection = LocalLayoutDirection.current + LazyColumn( + // Scaffold hands the content its insets (status bar, the bottom action bar and, + // in landscape, a side 3-button navigation bar); consume them so the IME padding + // below only adds what the keyboard covers beyond them. + modifier = + Modifier + .fillMaxSize() + .consumeWindowInsets(padding) + .imePaddingSafe(), + contentPadding = + PaddingValues( + start = padding.calculateStartPadding(layoutDirection), + top = padding.calculateTopPadding(), + end = padding.calculateEndPadding(layoutDirection), + bottom = padding.calculateBottomPadding() + 16.dp, + ), + ) { + item(key = "intro", contentType = "intro") { + BackupConflictIntro(conflict, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + eventDiffItems(conflict, presentation, ui, accountViewModel, nav) + } + } +} + +/** The default layout: removed / added / changed sections of labelled, typed entries. */ +internal fun LazyListScope.genericDiffItems( + rows: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed( + rows, + contentType = { _, row -> + when (row) { + is ReviewRow.Section -> "section" + is ReviewRow.Note -> "note" + is ReviewRow.Group -> "group" + is ReviewRow.Entry -> row.item::class.simpleName + } + }, + ) { _, row -> + when (row) { + is ReviewRow.Section -> SectionRow(row) + is ReviewRow.Note -> DetailText("• " + stringRes(row.textRes), Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + is ReviewRow.Group -> GroupLabel(row.labelRes) + is ReviewRow.Entry -> ReviewEntry(row.item, accountViewModel, nav) + } + } +} + +@Composable +internal fun GroupLabel(labelRes: Int) { + Text( + text = stringRes(labelRes), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 4.dp), + ) +} + +@Composable +internal fun SectionRow(row: ReviewRow.Section) { + val color = + when (row.tone) { + Tone.REMOVED -> MaterialTheme.colorScheme.error + Tone.ADDED -> MaterialTheme.colorScheme.primary + Tone.CHANGED -> MaterialTheme.colorScheme.tertiary + } + HorizontalDivider() + Text( + text = stringRes(row.titleRes, row.count.toString()), + style = MaterialTheme.typography.titleMedium, + color = color, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), + ) +} + +@Composable +private fun ReviewActions( + keepLabel: String, + restoreLabel: String, + onRestore: (() -> Unit)?, + onKeepNew: () -> Unit, +) { + Surface(tonalElevation = 3.dp) { + Row( + // Scaffold only insets its content slot; a bottomBar handles its own. The padding + // goes on the content, not the Surface, so the bar's background still reaches the + // screen edge while the buttons sit above the 3-button navigation (and above the + // keyboard, if one is up) instead of underneath it. + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePaddingSafe() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Keeping the new version is the default: it's what is on relays already. Restoring + // always reverts another app's change, so it is the red, secondary choice. + if (onRestore != null) { + OutlinedButton( + onClick = onRestore, + modifier = Modifier.weight(1f).height(52.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + ) { + Text(restoreLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.SemiBold) + } + } + Button(onClick = onKeepNew, modifier = Modifier.weight(1f).height(52.dp)) { + Text(keepLabel, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) + } + } + } +} + +/** "Follow list updated", "Mute list cleared"…: the event and what happened to it, as a title. */ +@Composable +internal fun eventTitle(conflict: ReplaceableBackupConflict): String { + val name = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() } + // Says what happened to it, not just what it is: "Community list updated". + val emptied = conflict.incoming.tags.isEmpty() && conflict.incoming.content.isEmpty() + return stringRes(if (emptied) R.string.backup_review_title_cleared else R.string.backup_review_title_updated, name) +} + +/** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */ +@Composable +private fun actionLabels(conflict: ReplaceableBackupConflict): Pair = + when (val diff = conflict.diff) { + is ContactListDiff -> { + val (saved, new) = conflict.followCounts + stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString()) + } + is MuteListDiff -> { + val removed = diff.publicMutes.removed.size + stringRes(R.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(R.string.backup_action_remute, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + } + is SimpleGroupListDiff -> { + val removed = diff.groups.removed.size + stringRes(R.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + } + is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile) + is RelayListDiff -> + if (conflict.eventType == BackupEventType.BLOCKED_RELAYS && diff.relays.removed.isNotEmpty()) { + stringRes(R.string.backup_conflict_keep_new) to + stringRes( + R.string.backup_action_block_again, + diff.relays.removed.size + .toString(), + ) + } else { + stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + } + is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + is ChannelListDiff -> rejoinLabels(diff.channels.removed.size) + is CommunityListDiff -> rejoinLabels(diff.communities.removed.size) + is EphemeralChatListDiff -> rejoinLabels(diff.rooms.removed.size) + is FavoriteAlgoFeedsListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_feeds) + is HashtagListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics) + // Geohashes are places, not topics: the screen counts "places gone" under "Followed + // locations", so the button has to agree with the page it sits on. + is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_places) + is TrustProviderListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_providers) + is CashuWalletDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_wallet) + else -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_conflict_restore_mine) + } + +@Composable +private fun rejoinLabels(removed: Int): Pair = + stringRes(R.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + +/** When the other app changed it, and what the event is for. */ +@Composable +private fun BackupConflictIntro( + conflict: ReplaceableBackupConflict, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), + style = MaterialTheme.typography.labelLarge, + color = conflictTones().removed, + ) + Text( + text = stringRes(eventTypeExplainer(conflict.eventType)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} + +internal val EntryPadding = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + +@Composable +internal fun ReviewEntry( + item: ReviewItem, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (item) { + is ReviewItem.Person -> PersonEntry(item, accountViewModel, nav) + is ReviewItem.Thread -> + LoadNote(item.eventId, accountViewModel) { note -> + if (note != null) { + Column(EntryPadding) { + NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav) + item.detail?.let { DetailText(it) } + } + } else { + TextEntry(item.eventId.toShortDisplay(), item.detail) + } + } + is ReviewItem.Addressable -> + LoadAddressableNote(item.address, accountViewModel) { note -> + if (note != null) { + Column(EntryPadding) { + NoteCompose(note, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, nav = nav) + item.detail?.let { DetailText(it) } + } + } else { + TextEntry(item.address.dTag.ifBlank { item.address.toValue() }, item.detail) + } + } + is ReviewItem.PublicChat -> PublicChatEntry(item, accountViewModel, nav) + is ReviewItem.ChatRoom -> + TextEntry(item.roomId.id + " @ " + item.roomId.relayUrl.url, item.detail) { nav.nav(routeFor(item.roomId)) } + is ReviewItem.Relay -> TextEntry(item.url.url, item.detail) { nav.nav(Route.RelayInfo(item.url.url)) } + is ReviewItem.Text -> TextEntry(item.text, item.detail) + } +} + +@Composable +private fun PersonEntry( + item: ReviewItem.Person, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadUser(item.pubKey, accountViewModel) { user -> + if (user == null) { + TextEntry(item.pubKey.toShortDisplay(), item.detail) + } else { + Row( + modifier = Modifier.fillMaxWidth().clickable { nav.nav(routeFor(user)) }.then(EntryPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture(user, Size35dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.padding(start = 10.dp).weight(1f)) { + // Subscribes to the user's metadata on relays, so unknown people resolve. + UsernameDisplay(user, accountViewModel = accountViewModel) + item.detail?.let { DetailText(it) } + } + } + } + } +} + +@Composable +private fun PublicChatEntry( + item: ReviewItem.PublicChat, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadPublicChatChannel(item.channelId, accountViewModel) { channel -> + // Subscribes to the channel's metadata on relays and recomposes when it arrives. + val state by observeChannel(channel, accountViewModel) + val name = state?.channel?.toBestDisplayName() ?: channel.toBestDisplayName() + TextEntry(name, item.detail) { nav.nav(routeFor(channel)) } + } +} + +@Composable +internal fun TextEntry( + text: String, + detail: String?, + onClick: (() -> Unit)? = null, +) { + val modifier = if (onClick != null) Modifier.fillMaxWidth().clickable(onClick = onClick) else Modifier.fillMaxWidth() + Column(modifier.then(EntryPadding)) { + Text( + text = text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + detail?.let { DetailText(it) } + } +} + +@Composable +internal fun DetailText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt new file mode 100644 index 0000000000..1d8e89a034 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictVisuals.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.warningColor +import com.vitorpamplona.quartz.utils.Hex + +/** The three meanings every conflict visual uses: lost, gained, edited. Theme-aware. */ +@Immutable +class ConflictTones( + val removed: Color, + val added: Color, + val changed: Color, + val kept: Color, +) { + fun removedContainer() = removed.copy(alpha = 0.14f) + + fun addedContainer() = added.copy(alpha = 0.14f) + + fun changedContainer() = changed.copy(alpha = 0.14f) +} + +@Composable +fun conflictTones(): ConflictTones { + val scheme = MaterialTheme.colorScheme + return ConflictTones( + removed = scheme.error, + added = scheme.allGoodColor, + changed = scheme.warningColor, + kept = scheme.primary, + ) +} + +/** "523 → 120": the saved size against the new one, the headline of list conflicts. */ +@Composable +fun HeroCounts( + savedLabel: String, + saved: Int, + newLabel: String, + new: Int, + modifier: Modifier = Modifier, +) { + val tones = conflictTones() + Row(modifier, verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Column { + Text(savedLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + Text(saved.toString(), fontSize = 44.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold) + } + Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(bottom = 6.dp)) + Column { + Text(newLabel, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + Text( + new.toString(), + fontSize = 44.sp, + lineHeight = 44.sp, + fontWeight = FontWeight.Bold, + color = if (new < saved) tones.removed else tones.added, + ) + } + } +} + +/** One segment of a [SplitBar]. */ +@Immutable +class BarSegment( + val weight: Int, + val color: Color, +) + +/** A single bar split into kept / dropped / new segments, proportional to their counts. */ +@Composable +fun SplitBar( + segments: List, + modifier: Modifier = Modifier, + height: Dp = 14.dp, +) { + val visible = segments.filter { it.weight > 0 } + Row( + modifier + .fillMaxWidth() + .height(height) + .clip(RoundedCornerShape(height / 2)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + visible.forEach { + Box(Modifier.weight(it.weight.toFloat()).fillMaxHeight().background(it.color)) + } + } +} + +/** A colored dot and a label: the legend under a [SplitBar]. */ +@Composable +fun LegendItem( + color: Color, + text: String, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(color)) + Text(text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText) + } +} + +/** A big signed number over a caption, on a tinted tile. */ +@Composable +fun StatTile( + value: String, + caption: String, + color: Color, + modifier: Modifier = Modifier, +) { + Column( + modifier + .clip(RoundedCornerShape(16.dp)) + .background(color.copy(alpha = 0.12f)) + .padding(vertical = 12.dp, horizontal = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(value, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = color) + Text(caption, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, textAlign = TextAlign.Center) + } +} + +/** A small uppercase tag: DROPPED, NEW, RENAMED… */ +@Composable +fun StatusTag( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + Text( + text = text.uppercase(), + modifier = modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.14f)).padding(horizontal = 8.dp, vertical = 3.dp), + color = color, + fontSize = 10.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.5.sp, + ) +} + +/** An uppercase section caption. */ +@Composable +fun SectionCaption( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text.uppercase(), + modifier = modifier, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +/** + * A 3×3 color fingerprint of a key, so two keys can be told apart at a glance without + * reading hex. Deterministic: the same key always draws the same grid. + */ +@Composable +fun KeyFingerprint( + hex: String, + borderColor: Color, + modifier: Modifier = Modifier, +) { + val bytes = runCatching { Hex.decode(hex) }.getOrNull() ?: ByteArray(0) + val colors = + List(9) { i -> + val b = if (bytes.isEmpty()) 0 else bytes[i % bytes.size].toInt() and 0xFF + val b2 = if (bytes.isEmpty()) 0 else bytes[(i + 9) % bytes.size].toInt() and 0xFF + Color.hsl(hue = b / 255f * 360f, saturation = 0.55f + (b2 % 30) / 100f, lightness = 0.62f) + } + Column( + modifier + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(2.dp) + .clip(RoundedCornerShape(16.dp)) + .background(borderColor) + .padding(3.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + colors.chunked(3).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { + row.forEach { Box(Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)).background(it)) } + } + } + } +} + +/** A tinted rounded panel, the base of every hero block. */ +@Composable +fun TintedPanel( + tint: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(tint) + .padding(18.dp), + ) { content() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 9d2516cc70..5a5742c3f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -36,6 +38,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -45,6 +49,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -52,6 +57,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst @@ -67,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.resources.feed_is_empty import com.vitorpamplona.amethyst.commons.resources.home_tab_everything import com.vitorpamplona.amethyst.commons.resources.new_threads import com.vitorpamplona.amethyst.commons.resources.refresh +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError +import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.PagerStateKeys import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys @@ -96,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.backups.BackupConflictCards import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.NewHashtagPostButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription @@ -395,7 +404,9 @@ fun HomeFeeds( nav = nav, routeForLastRead = routeForLastRead, onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) }, - onEmpty = { HomeFeedEmpty(onRefresh) }, + onEmpty = { WithBackupConflicts(accountViewModel, nav) { HomeFeedEmpty(onRefresh) } }, + onError = { WithBackupConflicts(accountViewModel, nav) { FeedError(it, onRefresh) } }, + onLoading = { WithBackupConflicts(accountViewModel, nav) { LoadingFeed() } }, ) } } @@ -428,11 +439,30 @@ fun FeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val backupConflicts by accountViewModel.account.settings.backupConflicts + .collectAsStateWithLifecycle() + + // The list keeps its position anchored to the first visible post, so cards that appear + // while the feed sits at the top would be inserted just above the screen. Only then (not + // when the user has scrolled down) bring them into view. + val hasConflicts = backupConflicts.isNotEmpty() + val atTop by remember(listState) { derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } } + val cardsAppearedAtTop = remember(hasConflicts) { hasConflicts && atTop } + LaunchedEffect(cardsAppearedAtTop) { + if (cardsAppearedAtTop) listState.scrollToItem(0) + } LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { + // Only while a conflict is open, so the list's first item (and with it how the + // scroll position anchors when new posts arrive) is unchanged the rest of the time. + if (hasConflicts) { + item(key = "backupConflicts", contentType = "backupConflicts") { + BackupConflictCards(backupConflicts, nav, onAccept = accountViewModel::acceptExternalBackupVersion) + } + } if (liveSection != null) { item { DisplayLiveBubbles(liveSection, accountViewModel, nav) @@ -513,6 +543,43 @@ fun HomeFeedEmptyPreview() { ) } +/** + * The backup-conflict cards above a feed that has no posts to show yet, so an empty or + * still-loading Home doesn't hide the question. They get at most half the screen and scroll + * there if there are many. + */ +@Composable +private fun WithBackupConflicts( + accountViewModel: AccountViewModel, + nav: INav, + content: @Composable () -> Unit, +) { + val backupConflicts by accountViewModel.account.settings.backupConflicts + .collectAsStateWithLifecycle() + if (backupConflicts.isEmpty()) { + content() + return + } + val padding = rememberFeedContentPadding(FeedPadding) + val layoutDirection = LocalLayoutDirection.current + Column(Modifier.fillMaxSize()) { + BackupConflictCards( + backupConflicts, + nav, + onAccept = accountViewModel::acceptExternalBackupVersion, + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding( + start = padding.calculateStartPadding(layoutDirection), + top = padding.calculateTopPadding(), + end = padding.calculateEndPadding(layoutDirection), + ), + ) + Box(Modifier.weight(1f)) { content() } + } +} + @Composable fun HomeFeedEmpty(onRefresh: () -> Unit) { Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 9ae32da6f9..edd22dba6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage @@ -50,7 +49,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isHlsMimeType import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl -import com.vitorpamplona.amethyst.commons.richtext.toCoilModel import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -256,16 +254,11 @@ fun UrlImageView( val isVideo = content is MediaUrlVideo val artworkUri = (content as? MediaUrlVideo)?.artworkUri - val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle() // Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8 // playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending // the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon. // Skip the fetch in that case and render blurhash + play overlay directly. - val bridgedUrl = - remember(content.url, useLocalBlossomBridge) { - content.toCoilModel(useLocalBlossomBridge) - } - val imageModelUrl = artworkUri ?: bridgedUrl + val imageModelUrl = artworkUri ?: content.url val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType) CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7fb92bcb67..1104e9be34 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -677,4 +677,322 @@ Sound on. Click to mute Play Pause + You changed your %1$s in another app + Removed (%1$s) + Added (%1$s) + Changed (%1$s) + You removed all private (encrypted) items. + You also changed private (encrypted) items. They can\'t be compared here. + You added private (encrypted) items. + Restore saved version + On this device + New version + Not set + %1$s removed + %1$s added + %1$s changed + + You unfollowed %1$d person + You unfollowed %1$d people + + Accept + %1$s updated + %1$s cleared + Changed in another app · %1$s + Keep %1$s + Restore %1$s + Re-mute %1$s + Rejoin %1$s + Restore profile + Restore relays + %1$s kept + %1$s dropped + %1$s new + Dropped · %1$s + New · %1$s + Edited · %1$s + Search %1$s people + Nothing here + %1$s unmuted + These can reach your feeds again + %1$s newly muted + Words & hashtags + %1$s and %2$s more + You removed all private items + They\'re encrypted, so only you can see what they were. Restoring brings them all back. + You made items private + They\'re encrypted now, which is likely where the public items above went. + You changed private items + They\'re encrypted, so they can\'t be compared here. + Gone + New + Dropped + Moved + Units + Left + Renamed + Outbox + where you publish + Inbox + where others reach you + Receiving key replaced + Receiving key removed + Nutzaps will be locked to this new key. If this device doesn\'t have it, you may not be able to redeem them here. + left + joined + renamed + was “%1$s” + Block %1$s again + Restore feeds + Restore topics + Restore places + Restore providers + Restore wallet + kept + Receiving + Listed + Searching + Storing + Trusted + Untrusted + %1$s unblocked + Amethyst will connect to them again + No longer blocked + Newly blocked + Still blocked + Allowed + Moved + Feed + topics gone + places gone + new + Hashtags you follow + Places you follow + %1$s of %2$s services changed + They now come from someone else, or no one + Service + Rank + Followers + Topics + Ranks, follower counts and spam filtering you see come from these providers. + + %1$d way to pay you removed + %1$d ways to pay you removed + + + %1$d offer removed + %1$d offers removed + + People paying you only see what\'s in this list. + BOLT12 offer + You emptied it + You created it + You changed it + Your wallet\'s mints and keys are encrypted to you, so they can\'t be compared item by item here. Restoring puts back exactly what this device saved. + Your Concord communities are encrypted to you, so they can\'t be compared here. Restoring puts back exactly what this device saved. + + You unmuted %1$d thing + You unmuted %1$d things + + + You removed %1$d profile field + You removed %1$d profile fields + + + You removed %1$d public relay + You removed %1$d public relays + + + You removed %1$d DM relay + You removed %1$d DM relays + + + You removed %1$d group-chat key relay + You removed %1$d group-chat key relays + + + You removed %1$d search relay + You removed %1$d search relays + + + You removed %1$d indexer relay + You removed %1$d indexer relays + + + You removed %1$d relay feed + You removed %1$d relay feeds + + + You removed %1$d private relay + You removed %1$d private relays + + + You stopped trusting %1$d relay + You stopped trusting %1$d relays + + + You unblocked %1$d relay + You unblocked %1$d relays + + + You left %1$d public chat + You left %1$d public chats + + + You left %1$d community + You left %1$d communities + + + You left %1$d chat room + You left %1$d chat rooms + + + You left %1$d group + You left %1$d groups + + + You removed %1$d favorite feed + You removed %1$d favorite feeds + + + You unfollowed %1$d hashtag + You unfollowed %1$d hashtags + + + You unfollowed %1$d place + You unfollowed %1$d places + + + You changed the provider of %1$d trust service + You changed the provider of %1$d trust services + + + You removed %1$d nutzap mint + You removed %1$d nutzap mints + + + You removed %1$d way to get paid + You removed %1$d ways to get paid + + + You removed %1$d BOLT12 offer + You removed %1$d BOLT12 offers + + + %1$s and added %2$d + %1$s and added %2$d + + + %1$s and followed %2$d + %1$s and followed %2$d + + + %1$s and muted %2$d + %1$s and muted %2$d + + + %1$s and joined %2$d + %1$s and joined %2$d + + + %1$s and blocked %2$d + %1$s and blocked %2$d + + Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. + You replaced your nutzap key + You removed your nutzap key + You emptied your Cashu wallet + You changed your Cashu wallet + You emptied your Concord list + You changed your Concord list + Use new version + read only + write only + read and write + profile + Your name, picture, bio and payment addresses, as everyone sees them. + follow list + Decides whose posts appear in your home feed. + mute list + Hides people, words, hashtags and threads from everything you see. + public relay list + Tells everyone where to find your posts and where to send you replies and mentions. + DM relay list + Where others deliver your private messages. + group-chat key relays + Where others find the keys to add you to encrypted group chats. + search relay list + The relays used when you search. + indexer relay list + The relays used to find other people\'s profiles and relay lists. + favorite relay feeds + The relays you browse as feeds. + blocked relay list + Relays this app never connects to. + trusted relay list + Relays you trust to connect to without asking. + private relay list + Where your drafts and private data are stored. + synced app settings + Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared. + public chat list + The public chats you joined. + community list + The communities you joined. + followed hashtags + Hashtags whose posts appear in your feeds. + followed locations + Locations whose posts appear in your feeds. + favorite feeds list + The algorithmic feeds you saved. + ephemeral chat list + The ephemeral chat rooms you joined. + group list + The relay-based groups you joined. + Concord community list + The Concord communities you joined. They are encrypted, so only their presence can be compared. + trust provider list + The services you trust to rank and verify people for you. + Cashu wallet + Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared. + nutzap receiving settings + The mints and key others use to send you nutzaps. + payment addresses + The ways other people can pay you. + BOLT12 offers + The lightning offers others use to pay you. + list + A list saved on this device. + Profile fields + People + Hashtags + Words + Threads + Public chats + Communities + Feeds + Locations + Relays + Groups + Chat rooms + Trust providers + Mints + Nutzap keys + Payment addresses + Offers + Linked identities + Other entries + Username + Display name + About + Picture + Banner + Website + Nostr address + Lightning address + LNURL + Pronouns + Birthday + CLINK offer + Bot flag diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt new file mode 100644 index 0000000000..dd2aa22c3d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/backups/BackupConflictStorageTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.backups + +import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupDiff +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.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * An undecided conflict has to survive the process that raised it. Before this, it lived only in + * a `MutableStateFlow`, so a restart answered the user's question for them by dropping it. + */ +class BackupConflictStorageTest { + private val signer = NostrSignerSync(KeyPair()) + + private fun sign( + kind: Int, + createdAt: Long, + tags: Array>, + content: String = "", + ): Event = signer.sign(createdAt, kind, tags, content) + + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + private fun savedAndIncoming(): Pair = + sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) to + sign(3, 200, arrayOf(arrayOf("p", carol))) + + @Test + fun survivesARoundTrip() { + val (saved, incoming) = savedAndIncoming() + + val restored = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, incoming, incoming)))) + + assertEquals(1, restored.size) + val (restoredSaved, restoredIncoming, restoredCause) = restored.first() + assertEquals(saved.id, restoredSaved.id) + assertEquals(incoming.id, restoredIncoming.id) + assertEquals(incoming.id, restoredCause.id) + // The events must come back whole, not as ids: the review screen diffs their contents. + assertEquals(2, restoredSaved.tags.count { it.firstOrNull() == "p" }) + } + + @Test + fun theRestoredPairStillDescribesTheSameLoss() { + val (saved, incoming) = savedAndIncoming() + + val (restoredSaved, restoredIncoming, _) = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, incoming, incoming)))).first() + + // The diff is recomputed rather than stored, so it has to still be derivable. + val diff = ReplaceableBackupDiff.detectLoss(restoredSaved, restoredIncoming) + assertNotNull(diff) + assertTrue(diff!!.removesData()) + } + + @Test + fun aVersionThatNoLongerRemovesAnythingStopsBeingAConflict() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice))) + val putBack = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + + val (restoredSaved, restoredIncoming, _) = BackupConflictStorage.decode(BackupConflictStorage.encode(listOf(Triple(saved, putBack, putBack)))).first() + + assertNull(ReplaceableBackupDiff.detectLoss(restoredSaved, restoredIncoming)) + } + + @Test + fun severalSlotsAreKeptApart() { + val (saved, incoming) = savedAndIncoming() + val mutesSaved = sign(10000, 100, arrayOf(arrayOf("p", alice))) + val mutesIncoming = sign(10000, 200, arrayOf()) + + val restored = + BackupConflictStorage.decode( + BackupConflictStorage.encode( + listOf(Triple(saved, incoming, incoming), Triple(mutesSaved, mutesIncoming, mutesIncoming)), + ), + ) + + assertEquals(2, restored.size) + assertEquals(listOf(3, 10000), restored.map { it.second.kind }) + } + + @Test + fun nothingStoredMeansNoConflicts() { + assertTrue(BackupConflictStorage.decode(null).isEmpty()) + assertTrue(BackupConflictStorage.decode("").isEmpty()) + assertTrue(BackupConflictStorage.encode(emptyList()).let { BackupConflictStorage.decode(it) }.isEmpty()) + } + + @Test + fun oneUnreadableEntryDoesNotTakeTheOthersWithIt() { + val (saved, incoming) = savedAndIncoming() + // A row whose events are not parseable, next to a good one. + val mixed = + JsonMapper.toJson( + listOf( + listOf("not an event", "not an event", "not an event"), + listOf( + OptimizedJsonMapper.toJson(saved), + OptimizedJsonMapper.toJson(incoming), + OptimizedJsonMapper.toJson(incoming), + ), + ), + ) + + val restored = BackupConflictStorage.decode(mixed) + + assertEquals(1, restored.size) + assertEquals(incoming.id, restored.first().second.id) + } + + @Test + fun garbageIsNotAConflict() { + assertTrue(BackupConflictStorage.decode("not json at all").isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt index 13e6966eeb..2bde041b66 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -206,7 +206,13 @@ class LocalBlossomCacheRedirectInterceptorTest { url: String, captured: MutableList, ): Interceptor.Chain { - val request = Request.Builder().url(url.toHttpUrl()).build() + // Marked the way the media loaders mark their downloads; only those are bridged. + val request = + Request + .Builder() + .url(url.toHttpUrl()) + .header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, LocalBlossomCacheRedirectInterceptor.MEDIA) + .build() return Proxy.newProxyInstance( Interceptor.Chain::class.java.classLoader, arrayOf(Interceptor.Chain::class.java), diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt new file mode 100644 index 0000000000..052d4266b1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/PickAccountDataRelaysTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * An account whose kind:10002 lists no write relays has no home relays, and this subscription is + * keyed on them. Keyed on them *alone*, such an account was asked about on no relay at all, so its + * own profile, follow list and every backed-up list silently stopped updating. + */ +class PickAccountDataRelaysTest { + private fun relays(vararg urls: String) = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + private val home = relays("wss://home.example.com") + private val inbox = relays("wss://inbox.example.com") + private val defaults = relays("wss://default1.example.com", "wss://default2.example.com") + + @Test + fun homeRelaysWinWhenThereAreAny() { + assertEquals(home, pickAccountDataRelays(home, inbox, defaults)) + } + + @Test + fun theInboxIsUsedWhenNothingIsPublishedAnywhere() { + assertEquals(inbox, pickAccountDataRelays(emptySet(), inbox, defaults)) + } + + /** The case that broke: everything the account declares is empty, and the answer was nothing. */ + @Test + fun defaultsRatherThanNothing() { + assertEquals(defaults, pickAccountDataRelays(emptySet(), emptySet(), defaults)) + } + + @Test + fun theAnswerIsNeverEmptyWhileThereAreDefaults() { + val cases = + listOf( + Triple(home, inbox, defaults), + Triple(emptySet(), inbox, defaults), + Triple(emptySet(), emptySet(), defaults), + ) + cases.forEach { (h, i, d) -> + assert(pickAccountDataRelays(h, i, d).isNotEmpty()) { "empty for home=$h inbox=$i" } + } + } + + @Test + fun theInboxDoesNotWidenAnAccountThatAlreadyPublishesSomewhere() { + // Reading its own events from where it publishes them is the normal path; the inbox is a + // fallback, not an addition, so a working account's relay set is unchanged by this rule. + assertEquals(home, pickAccountDataRelays(home, inbox, defaults)) + assertEquals(home, pickAccountDataRelays(home, emptySet(), defaults)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 13c243fb03..fa3b4eb6fb 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -48,9 +48,16 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -387,6 +394,48 @@ class RadioBurstEstimatorTest { } class LoopbackExclusionTest { + @get:Rule + val temp = TemporaryFolder() + + /** Runs [UsageCountingInterceptor] over a chain whose downstream sent [sentUrl] for [url]. */ + private fun countedRequests( + url: String, + sentUrl: String, + ): Long? { + val accountant = ResourceUsageAccountant(ResourceUsageStore(File(temp.root, "u.json")), CoroutineScope(Dispatchers.Unconfined), epochDay = { 1L }) + val interceptor = UsageCountingInterceptor(accountant, isMobile = { true }, isForeground = { true }, nowMs = { 0L }) + + val request = Request.Builder().url(url).build() + val chain = mockk() + every { chain.request() } returns request + every { chain.proceed(any()) } answers { + Response + .Builder() + .request(request.newBuilder().url(sentUrl).build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("".toResponseBody(null)) + .build() + } + interceptor.intercept(chain).close() + + return runBlocking { accountant.allDaysIncludingLive() }[1L].orEmpty()[UsageKeys.netReqs(UsageKeys.ROLE_OTHER, mobile = true, foreground = true)] + } + + @Test + fun requestRewrittenToTheLocalCacheIsNotCounted() { + // This interceptor is outermost: it sees the CDN URL, but the request that actually went + // out (LocalBlossomCacheRedirectInterceptor's rewrite) never touched the radio. + val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + assertNull(countedRequests("https://cdn.example.com/$sha.jpg", "http://127.0.0.1:24242/$sha.jpg")) + } + + @Test + fun requestToARealHostIsCounted() { + assertEquals(1L, countedRequests("https://cdn.example.com/a.jpg", "https://cdn.example.com/a.jpg")) + } + @Test fun loopbackHostsAreRecognizedAndRealHostsAreNot() { assertTrue(UsageCountingInterceptor.isLoopback("127.0.0.1")) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolverTest.kt new file mode 100644 index 0000000000..4706acce8c --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolverTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.uploads.blossom.bud10 + +import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +class BlossomServerResolverTest { + private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + private val author = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @After + fun tearDown() = scope.cancel() + + /** A resolver whose author server-list lookups are counted and answered by [serverList]. */ + private fun resolver( + lookups: AtomicInteger, + serverList: () -> Flow, + ) = BlossomServerResolver( + loggedInUsers = { emptyList() }, + blossomServers = { addresses -> + lookups.incrementAndGet() + addresses.map { serverList() } + }, + httpClientBuilder = mockk(relaxed = true), + scope = scope, + ) + + @Test + fun concurrentLookupsOfTheSameUriShareOneResolution() = + runBlocking { + val lookups = AtomicInteger(0) + // The author's server list never arrives, like a relay that is slow to answer. + val resolver = resolver(lookups) { flow { awaitCancellation() } } + val uri = "blossom:$sha.jpg?as=$author" + + val callers = List(5) { scope.launch { resolver.findServers(uri) } } + delay(300) + + assertEquals(1, lookups.get()) + callers.forEach { it.cancel() } + } + + @Test + fun anUnresolvableUriIsNotRetriedUntilTheMissExpiresOrCachesAreCleared() = + runBlocking { + val lookups = AtomicInteger(0) + // No hints and no authors: resolves to nothing right away. + val resolver = resolver(lookups) { flow { } } + val uri = "blossom:$sha.jpg" + + assertNull(resolver.findServers(uri)) + assertNull(resolver.findServers(uri)) + assertEquals(1, lookups.get()) + + resolver.clearCaches() + assertNull(resolver.findServers(uri)) + assertEquals(2, lookups.get()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt new file mode 100644 index 0000000000..ca4caa338f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuard.kt @@ -0,0 +1,242 @@ +/* + * 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.commons.model.backups + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * Stands in front of an account's replaceable-event backups and decides, for each newer + * version of a backed-up event, whether it may overwrite the saved copy or must be held back + * and questioned as a [ReplaceableBackupConflict]. + * + * Headless and platform-free so the rules can be unit-tested; the account's settings own one + * and call [accept] from every backup update. + * + * [isLocallySigned] tells versions this app produced from ones that came from relays; it + * defaults to [LocallySignedEvents]. [onChange] runs whenever the set of open conflicts + * changes, so the owner can persist it ([open] / [restore]). [reapply] hands an event back to + * the update that owns its slot: a conflict restored from storage has no retry closure until + * its version arrives again, and keeping it must work regardless. + */ +class BackupConflictGuard( + private val isLocallySigned: (HexKey) -> Boolean = LocallySignedEvents::contains, + private val onChange: () -> Unit = {}, + private val reapply: (Event) -> Unit = {}, +) { + private val _conflicts = MutableStateFlow>(emptyMap()) + + /** + * Newer versions of backed-up events that came from another client and dropped data the + * backup still has, keyed by [ReplaceableBackupConflict.slot]. While a slot is in here its + * backup is frozen on the saved version, so the user can still restore it. They outlive + * the process through [open] / [restore]: an undecided question must still be there on the + * next launch. + */ + val conflicts: StateFlow> = _conflicts + + // Everything below is only touched under the lock: the backup collectors run on IO + // threads while the user resolves conflicts from the UI thread. + private val lock = KmpLock() + + // Re-runs the update that raised each open conflict, once the user keeps the new version. + private val retries = HashMap Unit>() + + // Versions whose conflict is being restored. They must not raise the same conflict again + // while the saved version is re-signed. + private val resolving = HashSet() + + // Externally signed versions the user explicitly chose to keep over the backup. + private val accepted = HashSet() + + // Slots with a restore in flight, and those whose backup moved on meanwhile: a failed + // restore must not reopen a conflict about a version that is no longer the newest. + private val restoringSlots = HashSet() + private val movedOnWhileRestoring = HashSet() + + /** + * Decides whether [incoming] may overwrite the [saved] backup. Versions signed by this app + * always may. A newer version signed elsewhere that removed items from the saved one is + * held back and raised as a [ReplaceableBackupConflict]; [retry] re-runs the caller's + * update once the user keeps it. + * + * An [isEmpty] version (a wiped list) always reaches the diff, so wiping everything is + * questioned like any other loss; it only becomes the backup when this app signed it or + * the user chose to keep it. + */ + fun accept( + saved: T?, + incoming: T, + isEmpty: Boolean = false, + retry: () -> Unit, + ): Boolean = + lock.withLock { + if (saved?.id == incoming.id) return@withLock false + if (incoming.id in resolving) return@withLock false + // Never roll the backup back, e.g. by the retry of a conflict whose version has + // been overtaken since it was raised. + if (saved != null && incoming.createdAt < saved.createdAt) return@withLock false + + val slot = backupSlot(incoming) + val pending = _conflicts.value[slot] + + if (pending != null) { + // The same version re-emitted by the cache: the open conflict already describes it. + // Its retry is refreshed too: a conflict restored from storage carries none + // until the version it is about comes round again. + if (pending.incoming.id == incoming.id) { + retries[slot] = retry + return@withLock false + } + // An older version (e.g. the retry of a just-accepted conflict that a newer + // external change overtook) must never replace or clear the newer question. + if (incoming.createdAt < pending.incoming.createdAt) return@withLock false + } + + val isLocal = isLocallySigned(incoming.id) + val wasAccepted = incoming.id in accepted + + // While a conflict is open, even edits made here are built on top of the external + // version, so they are still compared with the version the user may want to restore. + val reference = pending?.saved ?: saved?.takeUnless { isLocal } + + if (reference != null && !wasAccepted) { + val diff = ReplaceableBackupDiff.detectLoss(reference, incoming) + if (diff != null) { + // A local edit keeps the external change as the conflict's cause. + val cause = if (isLocal && pending != null) pending.cause else incoming + retries[slot] = retry + _conflicts.update { it + (slot to ReplaceableBackupConflict(reference, incoming, cause, diff)) } + onChange() + return@withLock false + } + } + + dropLocked(slot) + // A wipe from elsewhere that lost nothing (e.g. the backup was empty too) doesn't + // become the backup; one signed here or explicitly kept does. + val stored = !isEmpty || isLocal || wasAccepted + if (stored) { + accepted.remove(incoming.id) + if (slot in restoringSlots) movedOnWhileRestoring.add(slot) + } + stored + } + + /** Forgets the open conflict of [slot], e.g. because its event was deleted. */ + fun drop(slot: String) { + lock.withLock { dropLocked(slot) } + } + + private fun dropLocked(slot: String) { + if (_conflicts.value.containsKey(slot)) { + _conflicts.update { it - slot } + onChange() + } + retries.remove(slot) + } + + /** + * Re-seeds the open conflicts read back from storage, as (saved, incoming, cause). + * + * Their diff is recomputed rather than stored, so a version that no longer removes + * anything (the other app put it back) quietly stops being a conflict. + */ + fun restore(saved: List>) { + if (saved.isEmpty()) return + val restored = + saved.mapNotNull { (savedEvent, incoming, cause) -> + val diff = ReplaceableBackupDiff.detectLoss(savedEvent, incoming) ?: return@mapNotNull null + val conflict = ReplaceableBackupConflict(savedEvent, incoming, cause, diff) + conflict.slot to conflict + } + if (restored.isEmpty()) return + lock.withLock { _conflicts.update { it + restored } } + } + + /** The open conflicts, as the three events storage needs to rebuild each one. */ + fun open(): List> = _conflicts.value.values.map { Triple(it.saved, it.incoming, it.cause) } + + /** + * Removes [conflict] if it is still the open conflict of its slot and returns its retry. + * Returns null when it was already resolved or replaced by a newer one, so callers never + * act on a stale conflict. + */ + private fun claimLocked(conflict: ReplaceableBackupConflict): (() -> Unit)? { + if (_conflicts.value[conflict.slot] !== conflict) return null + _conflicts.update { it - conflict.slot } + onChange() + // A conflict restored from storage has no closure to re-run: rebuild it from the event. + // Falling back to {} instead would clear the card and quietly keep the old backup. + return retries.remove(conflict.slot) ?: { reapply(conflict.incoming) } + } + + /** Keeps the new version: the backup moves forward to [ReplaceableBackupConflict.incoming]. */ + fun keepIncoming(conflict: ReplaceableBackupConflict) { + val retry = + lock.withLock { + val claimed = claimLocked(conflict) ?: return + accepted.add(conflict.incoming.id) + claimed + } + // Outside the lock: the retry re-enters accept. + retry() + } + + /** + * Starts restoring the saved version: claims [conflict] and stops its incoming version + * from raising it again while the saved one is re-signed. Returns null when the conflict + * is stale (already resolved, replaced or dropped); otherwise a token to hand back to + * [finishRestoring]. + */ + fun startRestoring(conflict: ReplaceableBackupConflict): (() -> Unit)? = + lock.withLock { + val retry = claimLocked(conflict) ?: return@withLock null + resolving.add(conflict.incoming.id) + restoringSlots.add(conflict.slot) + retry + } + + /** + * Ends a restore. On failure the conflict is reopened so the user can try again, unless + * something else (a newer change, a deletion) took the slot meanwhile. + */ + fun finishRestoring( + conflict: ReplaceableBackupConflict, + token: () -> Unit, + restored: Boolean, + ) { + lock.withLock { + resolving.remove(conflict.incoming.id) + restoringSlots.remove(conflict.slot) + val movedOn = movedOnWhileRestoring.remove(conflict.slot) + if (restored || movedOn || _conflicts.value.containsKey(conflict.slot)) return@withLock + _conflicts.update { it + (conflict.slot to conflict) } + retries[conflict.slot] = token + onChange() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt new file mode 100644 index 0000000000..401a992b83 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/LocallySignedEvents.kt @@ -0,0 +1,55 @@ +/* + * 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.commons.model.backups + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable + +/** + * Ids of the replaceable/addressable events this app instance produced (signed here, or + * restored from its own on-disk backup) since the process started. + * + * The backup guard uses it to tell "the user edited this list in Amethyst" from "a newer + * version came in from a relay", i.e. from another client. Only the latter can silently + * wipe data another app did not know about, so only the latter is ever questioned. + * + * Fed from the cache's `justConsumeMyOwnEvent` choke point, which every local publish goes + * through. Only replaceable and addressable kinds are tracked, and the ids live in a bounded + * LRU: only the latest few versions of each list matter, since an older version is already + * superseded by the time the backup guard sees a newer one. + */ +object LocallySignedEvents { + private const val MAX_TRACKED = 500 + + // androidx LruCache synchronizes internally, so the relay and UI threads can share it. + private val ids = LruCache(MAX_TRACKED) + + fun mark(event: Event) { + if (event.kind.isReplaceable() || event.kind.isAddressable()) { + ids.put(event.id, true) + } + } + + fun contains(eventId: HexKey): Boolean = ids[eventId] != null +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt new file mode 100644 index 0000000000..dd348ce0da --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt @@ -0,0 +1,203 @@ +/* + * 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.commons.model.backups + +import androidx.compose.runtime.Immutable +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.Event +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent + +/** Which of the account's backed-up events a conflict is about. Drives the dialog's wording. */ +enum class BackupEventType { + PROFILE, + FOLLOW_LIST, + MUTE_LIST, + OUTBOX_INBOX_RELAYS, + DM_RELAYS, + KEY_PACKAGE_RELAYS, + SEARCH_RELAYS, + INDEXER_RELAYS, + RELAY_FEEDS, + BLOCKED_RELAYS, + TRUSTED_RELAYS, + PRIVATE_OUTBOX_RELAYS, + APP_SETTINGS, + PUBLIC_CHATS, + COMMUNITIES, + HASHTAGS, + GEOHASHES, + FAVORITE_ALGO_FEEDS, + EPHEMERAL_CHATS, + RELAY_GROUPS, + CONCORD_COMMUNITIES, + TRUST_PROVIDERS, + CASHU_WALLET, + NUTZAP_INFO, + PAYMENT_TARGETS, + BOLT12_OFFERS, + OTHER, + ; + + companion object { + fun of(kind: Int): BackupEventType = + when (kind) { + MetadataEvent.KIND -> PROFILE + ContactListEvent.KIND -> FOLLOW_LIST + MuteListEvent.KIND -> MUTE_LIST + AdvertisedRelayListEvent.KIND -> OUTBOX_INBOX_RELAYS + ChatMessageRelayListEvent.KIND -> DM_RELAYS + KeyPackageRelayListEvent.KIND -> KEY_PACKAGE_RELAYS + SearchRelayListEvent.KIND -> SEARCH_RELAYS + IndexerRelayListEvent.KIND -> INDEXER_RELAYS + RelayFeedsListEvent.KIND -> RELAY_FEEDS + BlockedRelayListEvent.KIND -> BLOCKED_RELAYS + TrustedRelayListEvent.KIND -> TRUSTED_RELAYS + PrivateOutboxRelayListEvent.KIND -> PRIVATE_OUTBOX_RELAYS + AppSpecificDataEvent.KIND -> APP_SETTINGS + ChannelListEvent.KIND -> PUBLIC_CHATS + CommunityListEvent.KIND -> COMMUNITIES + HashtagListEvent.KIND -> HASHTAGS + GeohashListEvent.KIND -> GEOHASHES + FavoriteAlgoFeedsListEvent.KIND -> FAVORITE_ALGO_FEEDS + EphemeralChatListEvent.KIND -> EPHEMERAL_CHATS + SimpleGroupListEvent.KIND -> RELAY_GROUPS + ConcordCommunityListEvent.KIND -> CONCORD_COMMUNITIES + TrustProviderListEvent.KIND -> TRUST_PROVIDERS + CashuWalletEvent.KIND -> CASHU_WALLET + NutzapInfoEvent.KIND -> NUTZAP_INFO + PaymentTargetsEvent.KIND -> PAYMENT_TARGETS + Bolt12OfferListEvent.KIND -> BOLT12_OFFERS + else -> OTHER + } + } +} + +/** + * A newer version of one of the account's backed-up replaceable events arrived from outside + * this app and dropped data the saved version had. The backup keeps [saved] until the user + * either accepts [incoming] or re-signs [saved] on top of it. + * + * @property incoming the current version: what "use new version" keeps. Edits made in this + * app while the conflict is open are built on top of the external version, so they replace + * [incoming] but keep the conflict open. + * @property cause the version from the other app that raised the conflict. It stays the same + * while local edits move [incoming] forward, so the dialog keeps describing the external change. + * @property diff what [incoming] lost compared with [saved]. + */ +@Immutable +class ReplaceableBackupConflict( + val saved: Event, + val incoming: Event, + val cause: Event, + val diff: EventDiff, +) { + /** One conflict per replaceable slot: kind for replaceables, kind + d-tag for addressables. */ + val slot: String = backupSlot(incoming) + + val eventType: BackupEventType get() = BackupEventType.of(incoming.kind) + + /** + * Distinct people followed by the saved and the incoming version, for follow-list + * conflicts (0 otherwise). Parsed once: a follow list can have thousands of entries, and + * the Home card, the review screen and its buttons all show these counts. + */ + val followCounts: Pair by lazy { uniqueFollows(saved) to uniqueFollows(incoming) } + + private fun uniqueFollows(event: Event) = (event as? ContactListEvent)?.follows()?.mapTo(HashSet()) { it.pubKey }?.size ?: 0 +} + +fun backupSlot(event: Event): String = backupSlotOf(event.kind, event.dTag()) + +fun backupSlotOf( + kind: Int, + dTag: String = "", +): String = "$kind:$dTag" + +object ReplaceableBackupDiff { + /** + * What a newer [incoming] version of a backed-up event removed from the [saved] one, as + * computed by the event itself ([DiffableEvent.diffFrom]), or null when it removed + * nothing: it only added or edited entries, which means the other app did read the + * previous version. Events that can't diff themselves never raise a conflict. + */ + fun detectLoss( + saved: Event, + incoming: Event, + ): EventDiff? { + if (saved.id == incoming.id) return null + // Older or same-age versions never replace the backup in LocalCache anyway. + if (incoming.createdAt <= saved.createdAt) return null + val diffable = incoming as? DiffableEvent<*> ?: return null + return diffable.diffFrom(saved)?.takeIf { it.removesData() } + } +} + +object BackupRestore { + /** + * The tags to re-sign when restoring [saved] over a newer version. Metadata that only + * described the old signing is dropped, not the user's data: + * - `client`: the app that signs now adds its own (it won't when one is already there); + * - `nonce`: NIP-13 proof of work is tied to the old id and would be a false claim; + * - `expiration` already in the past: relays would reject or drop the restored version. + */ + fun tagsToResign( + saved: Event, + now: Long, + ): Array> = + saved.tags + .filterNot { tag -> + when (tag.getOrNull(0)) { + ClientTag.TAG_NAME, PoWTag.TAG_NAME -> true + ExpirationTag.TAG_NAME -> ExpirationTag.parse(tag)?.let { it <= now } ?: true + else -> false + } + }.toTypedArray() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 9775a4a596..c9e15d642c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus import com.vitorpamplona.amethyst.commons.model.RelayGroupTargetCandidate import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState @@ -3383,7 +3384,10 @@ open class EventCache : justConsume(event, null, false) } - override fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true) + override fun justConsumeMyOwnEvent(event: Event): Boolean { + LocallySignedEvents.mark(event) + return justConsumeAndUpdateIndexes(event, null, true) + } fun justConsume( event: Event, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt deleted file mode 100644 index 8d64214427..0000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.richtext - -import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri - -private val sha256HexRegex = Regex("[0-9a-f]{64}") -private val blossomLastSegmentRegex = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$") - -/** - * Converts this media content into a Coil/ExoPlayer-friendly model string. - * - * When the local-Blossom-cache bridge is active and the content has a - * known sha256 hash, returns a `blossom:.?xs=&as=` - * URI. The Coil pipeline recognises the scheme and routes the request - * through `BlossomServerResolver`, which short-circuits to the local cache - * at `127.0.0.1:24242`. - * - * Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI, - * or a live stream) returns the original URL unchanged so today's - * direct-to-CDN behaviour is preserved. - */ -fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String = - bridgeUrl( - url = url, - useBridge = useLocalBlossomBridge, - mimeType = mimeType, - authorPubKey = authorPubKey, - skipBridge = this is MediaUrlVideo && isLiveStream, - ) - -/** - * Bridge entry point for raw URL strings (e.g. profile pictures) that go - * through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore - * bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing - * entirely. - * - * Returns a direct `http://127.0.0.1:24242/.?xs=&as=` - * URL so the request can flow through `NetworkFetcher` unchanged. Falls - * back to the original URL when no sha256 can be recovered from the path - * or when the bridge is off. - * - * @param localCacheBase the local Blossom cache origin (default `http://127.0.0.1:24242`). - * @param authorPubKey 64-char lowercase hex pubkey appended as `as=` so the - * cache can consult that author's BUD-03 server list on miss. - */ -fun bridgeProfilePictureUrl( - url: String?, - useBridge: Boolean, - authorPubKey: String? = null, - localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE, -): String? { - if (url == null) return null - if (!useBridge) return url - if (url.startsWith("blossom:", ignoreCase = true)) return url - if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url - - val sha = extractSha256FromUrlPath(url) ?: return url - val ext = guessExtension(url, null) - val serverBase = extractServerBase(url, sha) ?: return url - - val params = - buildList { - add("xs=${percentEncode(serverBase)}") - authorPubKey - ?.lowercase() - ?.takeIf { sha256HexRegex.matches(it) } - ?.let { add("as=$it") } - } - - return "${localCacheBase.removeSuffix("/")}/$sha.$ext?${params.joinToString("&")}" -} - -const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242" - -private fun bridgeUrl( - url: String, - useBridge: Boolean, - mimeType: String?, - authorPubKey: String?, - skipBridge: Boolean, -): String { - if (!useBridge || skipBridge) return url - if (url.startsWith("blossom:", ignoreCase = true)) return url - if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url - - // The local Blossom cache fetches `/.` on miss per BUD-01, - // which only works when the upstream URL is itself BUD-01 layout — the - // file at `/.` is the one named in the URL path, not the - // imeta `x` hash (which on resizing CDNs may identify a different blob - // than the URL filename, e.g. `x` = post-resize, `ox` = original). - // Always use the URL's sha; never trust the imeta override. - val sha = extractSha256FromUrlPath(url) ?: return url - - val ext = guessExtension(url, mimeType) - val serverBase = extractServerBase(url, sha) ?: return url - - val authors = - authorPubKey - ?.lowercase() - ?.takeIf { sha256HexRegex.matches(it) } - ?.let { listOf(it) } - ?: emptyList() - - return BlossomUri( - sha256 = sha, - extension = ext, - servers = listOf(serverBase), - authors = authors, - size = null, - ).toUriString() -} - -private fun percentEncode(input: String): String { - val sb = StringBuilder(input.length) - for (c in input) { - if (c.isLetterOrDigit() || c in "-._~") { - sb.append(c) - } else { - for (b in c.toString().encodeToByteArray()) { - sb.append('%') - sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) - } - } - } - return sb.toString() -} - -private fun extractSha256FromUrlPath(url: String): String? { - // Per Blossom (BUD-01) the last path segment must be exactly - // `` or `.`. URLs whose filename merely embeds a - // 64-char hex (e.g. "nostr.build_.jpg") aren't Blossom blobs and - // the bridge must leave them alone — rewriting them would point the - // local cache at a fallback `xs=` server that doesn't host the blob. - val pathPart = url.substringBefore('?').substringBefore('#') - val lastSegment = pathPart.substringAfterLast('/') - val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null - return match.groupValues[1].lowercase() -} - -private fun guessExtension( - url: String, - mimeType: String?, -): String { - val pathPart = url.substringBefore('?').substringBefore('#') - val lastDot = pathPart.lastIndexOf('.') - val lastSlash = pathPart.lastIndexOf('/') - if (lastDot > lastSlash && lastDot >= 0) { - val ext = pathPart.substring(lastDot + 1).lowercase() - if (ext.isNotEmpty() && ext.length <= 8 && ext.all { it.isLetterOrDigit() }) { - return ext - } - } - - if (mimeType != null) { - for ((extension, mt) in mimeTypeMap) { - if (mt.equals(mimeType, ignoreCase = true)) return extension - } - } - - return "bin" -} - -/** - * Returns the URL prefix that the local Blossom cache should append `/` - * to in order to reach the original blob, preserving any path prefix the - * upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's - * `/i/` scheme). Falls back to scheme+host when the sha can't be - * located in the path. - */ -private fun extractServerBase( - url: String, - sha: String, -): String? { - val pathPart = url.substringBefore('?').substringBefore('#') - val schemeEnd = pathPart.indexOf("://") - if (schemeEnd < 0) return null - val hostStart = schemeEnd + 3 - if (hostStart >= pathPart.length) return null - - val shaIndex = pathPart.lastIndexOf(sha, ignoreCase = true) - if (shaIndex >= 0) { - // Anchor on the slash immediately preceding the sha so the cache - // can append "/" verbatim per the local-blossom-cache spec. - val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1) - if (slashBeforeSha > hostStart - 1) { - return pathPart.substring(0, slashBeforeSha) - } - } - return extractHostBase(pathPart) -} - -private fun extractHostBase(url: String): String? { - val schemeEnd = url.indexOf("://") - if (schemeEnd < 0) return null - val afterScheme = schemeEnd + 3 - var end = url.length - for (i in afterScheme until url.length) { - val c = url[i] - if (c == '/' || c == '?' || c == '#') { - end = i - break - } - } - if (end <= afterScheme) return null - return url.substring(0, end) -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/routes/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/routes/Routes.kt index d5a15d64d7..1e167ebc7f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/routes/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/routes/Routes.kt @@ -825,6 +825,10 @@ sealed class Route { val replyTo: HexKey? = null, ) : Route() + @Serializable data class BackupConflictReview( + val slot: String, + ) : Route() + @Serializable data class RelayInfo( val url: String, ) : Route() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt deleted file mode 100644 index 16e4427cac..0000000000 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.richtext - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class MediaUrlContentExtTest { - private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" - - @Test - fun bridgeOffReturnsOriginalUrl() { - val image = MediaUrlImage(url = "https://cdn.example.com/$sha.jpg", hash = sha) - assertEquals("https://cdn.example.com/$sha.jpg", image.toCoilModel(useLocalBlossomBridge = false)) - } - - @Test - fun nullHashReturnsOriginalUrl() { - val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = null) - assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun invalidHashReturnsOriginalUrl() { - val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = "not-hex") - assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun blossomUriReturnedUnchanged() { - val image = MediaUrlImage(url = "blossom:$sha.jpg?xs=https://cdn.example.com", hash = sha) - assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun liveStreamReturnsOriginalUrl() { - val video = MediaUrlVideo(url = "https://stream.example.com/play.m3u8", hash = sha, isLiveStream = true) - assertEquals("https://stream.example.com/play.m3u8", video.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun bridgeOnRewritesPlainHttpsUrl() { - val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertEquals("blossom:$sha.jpg?xs=https://nostr.build/i/abc", result) - } - - @Test - fun bridgeOnPreservesNostrBuildPathPrefix() { - val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result) - } - - @Test - fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() { - val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", result) - } - - @Test - fun bridgeOnInfersExtensionFromMimeType() { - // BUD-01 allows `` without an extension; mimeType supplies one. - val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png") - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result") - } - - @Test - fun bridgeOnFallsBackToBinExtension() { - val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result") - } - - @Test - fun nonHttpUrlReturnsOriginal() { - val image = MediaUrlImage(url = "ftp://example.com/file", hash = sha) - assertEquals("ftp://example.com/file", image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun uppercaseHashNormalisedToLowercase() { - val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase()) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result") - } - - @Test - fun authorPubKeyAddedAsAsParam() { - val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" - val image = - MediaUrlImage( - url = "https://cdn.example.com/$sha.jpg", - hash = sha, - authorPubKey = authorPub, - ) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result) - } - - @Test - fun invalidAuthorPubKeyDropped() { - val image = - MediaUrlImage( - url = "https://cdn.example.com/$sha.jpg", - hash = sha, - authorPubKey = "not-a-pubkey", - ) - val result = image.toCoilModel(useLocalBlossomBridge = true) - assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result) - } - - @Test - fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() { - // The imeta `x` hash refers to a blob whose canonical Blossom location - // is /., but the upstream URL serves it under a different - // path (https://i.nostr.build/M5AwJ.gif). Routing this through the - // local cache would set xs=https://i.nostr.build, and the cache would - // fetch https://i.nostr.build/.gif on miss, which 404s. - val url = "https://i.nostr.build/M5AwJ.gif" - val image = MediaUrlImage(url = url, hash = sha) - assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() { - val url = "https://i.nostr.build/M5AwJ.gif" - assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) - } - - @Test - fun bridgeProfilePictureUrlNullReturnsNull() { - assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true)) - } - - @Test - fun bridgeProfilePictureUrlOffReturnsOriginal() { - assertEquals( - "https://cdn.example.com/avatar.jpg", - bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false), - ) - } - - @Test - fun bridgeProfilePictureUrlExtractsShaFromPath() { - val url = "https://nostr.build/i/$sha.jpg" - val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" - assertEquals( - "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub", - bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub), - ) - } - - @Test - fun bridgeProfilePictureUrlPreservesNostrBuildPath() { - val url = "https://cdn.nostr.build/i/$sha.jpg" - assertEquals( - "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi", - bridgeProfilePictureUrl(url, useBridge = true), - ) - } - - @Test - fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() { - val url = "https://nostr.build/avatar.jpg" - assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) - } - - @Test - fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() { - val uri = "blossom:$sha.jpg?xs=https://nostr.build" - assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true)) - } - - @Test - fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() { - // share.yabu.me layout: /. - val image = - MediaUrlImage( - url = "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp", - hash = null, - ) - assertEquals( - "blossom:28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", - image.toCoilModel(useLocalBlossomBridge = true), - ) - } - - @Test - fun bridgeProfilePictureUrlRewritesShaInLastPathSegmentWithHexPrefix() { - assertEquals( - "http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", - bridgeProfilePictureUrl( - "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp", - useBridge = true, - ), - ) - } - - @Test - fun bridgeOnSkipsWhenLastSegmentIsNotSha() { - // Per BUD-01 the last segment is the blob; if it isn't a sha256, the - // URL isn't a Blossom blob even if an earlier segment is hex. - val url = "https://example.com/$sha/avatar.jpg" - val image = MediaUrlImage(url = url, hash = null) - assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun bridgeProfilePictureUrlSkipsWhenLastSegmentIsNotSha() { - val url = "https://example.com/$sha/avatar.jpg" - assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) - } - - @Test - fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { - // nostr.build /i/ layout: _.. The hex inside the - // filename isn't a Blossom blob per BUD-01 — the last segment must - // be exactly or .. - val url = "https://nostr.build/i/nostr.build_$sha.jpg" - val image = MediaUrlImage(url = url, hash = null) - assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { - val url = "https://nostr.build/i/nostr.build_$sha.jpg" - assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) - } - - @Test - fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() { - val url = "https://example.com/${sha}_thumb.jpg" - val image = MediaUrlImage(url = url, hash = null) - assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) - } - - @Test - fun bridgeOnUsesUrlShaNotImetaWhenTheyDiffer() { - // On resizing CDNs the imeta `x` (post-resize) can differ from the - // `ox` (original) embedded in the URL. The upstream file is named - // after the URL's sha, so the cache request must use that — using - // the imeta `x` would point xs= at a non-existent path on miss. - val urlSha = "f24026b7281e598973a775adefb1b9a13b9f037a94ac98dd48ccc91b83f4b7b3" - val imetaX = "6932a918de1bfae3bf6611794ff54dd677013d22b760a9212117a0bd9079badf" - val image = MediaUrlImage(url = "https://image.nostr.build/$urlSha.png", hash = imetaX) - assertEquals( - "blossom:$urlSha.png?xs=https://image.nostr.build", - image.toCoilModel(useLocalBlossomBridge = true), - ) - } -} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/DualHttpClientManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/DualHttpClientManager.kt index 8b13761f03..4ebc6a8a99 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/DualHttpClientManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/DualHttpClientManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.service.http +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted @@ -41,7 +42,7 @@ class DualHttpClientManager( keyCache: EncryptionKeyCache, scope: CoroutineScope, dns: SurgeDns, - shouldBridgeBlossomCache: (() -> Boolean)? = null, + shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null, // Required (not nullable): every general-purpose HTTP client we mint must be // wired into the OnionLocationCache so the app's `.onion`-routing behavior // is uniform across image, upload, NIP-05, money, preview, and push roles. @@ -53,8 +54,10 @@ class DualHttpClientManager( // Signs BUD-01 read-auth to retry auth-gated Blossom downloads on 401. // See [BlossomReadAuthInterceptor]. blossomReadAuth: Interceptor? = null, + // Told when the local Blossom cache refuses a connection. See [LocalBlossomCacheRedirectInterceptor]. + onLocalBlossomCacheUnreachable: () -> Unit = {}, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth, onLocalBlossomCacheUnreachable) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> @@ -96,10 +99,23 @@ class DualHttpClientManager( /** * the okhttp can change on the manager without affecting other systems. + * + * [useProxy] is fixed when the factory is built (e.g. per video player pool), from the URL the + * caller knew at the time. The URL that is finally requested can differ: a `blossom:` URI is + * resolved to the local Blossom cache on `127.0.0.1:24242` only when the data source opens. Tor + * refuses to connect to loopback/private addresses, so those requests must never take the proxied + * client — the same rule RoleBasedHttpClientBuilder applies to URLs it sees upfront. */ class DynamicCallFactory( val useProxy: Boolean, val manager: DualHttpClientManager, ) : Call.Factory { - override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request) + override fun newCall(request: Request): Call = manager.getHttpClient(shouldUseProxy(useProxy, request.url.toString())).newCall(request) + + companion object { + fun shouldUseProxy( + useProxy: Boolean, + url: String, + ): Boolean = useProxy && !RelayUrlNormalizer.isLocalHost(url) && !RelayUrlNormalizer.isOverlayNetwork(url) + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectInterceptor.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectInterceptor.kt index 12e73c79cf..df52ddaf64 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectInterceptor.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectInterceptor.kt @@ -20,43 +20,107 @@ */ package com.vitorpamplona.amethyst.commons.service.http +import okhttp3.Call import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response +import java.net.ConnectException /** - * App-wide OkHttp interceptor that transparently rewrites HTTP requests for + * OkHttp interceptor that transparently rewrites media downloads of * sha256-keyed blobs to a local Blossom cache running on `127.0.0.1:24242`, * per https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md * - * Activates when [shouldBridge] returns `true` AND the request URL contains - * a 64-char hex sha256 segment in its path AND the host isn't already - * `127.0.0.1`/`localhost`. The original scheme+host is appended as a `xs=` - * proxy hint so the cache can fetch upstream on miss. + * This is the only place feed media, videos and profile pictures are routed + * to the cache: callers keep the real URL, so the Tor decision, Coil/ExoPlayer + * cache keys and decryption-key lookups all see the origin. * - * Coil's disk cache keys responses by the original `ImageRequest.data`, so - * disk caching continues to work transparently even though the network - * request now goes to localhost. + * Bridging is **opt-in**: only requests the media loaders mark with the + * [MEDIA_HEADER] header are considered (see [LocalBlossomMediaCallFactory]). + * Anything else whose last path segment merely looks like a hash — a BUD-02 + * `GET /list/`, uploads, deletes, `HEAD` presence checks — must reach + * its server. The marker header is always stripped, so no server ever sees it; + * that is why Tor-proxied clients carry a non-bridging instance ([bridges] = + * false) instead of none: Tor-routed media skips the cache but is still cleaned. + * + * A marked `GET` is rewritten when [shouldBridge] returns `true`, it carries no + * `Authorization` (an auth-gated blob goes to the host the token was signed + * for), its last path segment is `[.ext]`, and the host isn't already + * the cache. The original scheme+host(+path prefix) is passed as the `xs=` + * hint so the cache can fetch upstream on miss. + * + * Encrypted blobs are looked up in [keyCache] by URL after this interceptor + * runs, so their decryption key is registered under the rewritten URL too. + * + * When the cache refuses the connection (the app was closed since the last + * probe), [onUnreachable] is told so the bridge can switch off, and the + * request falls back to its original URL instead of failing. */ class LocalBlossomCacheRedirectInterceptor( - private val shouldBridge: () -> Boolean, + private val keyCache: EncryptionKeyCache? = null, + private val onUnreachable: () -> Unit = {}, + val bridges: Boolean = true, + // Last, so the `LocalBlossomCacheRedirectInterceptor { enabled }` trailing-lambda form binds here. + // Told whether the request is a profile picture, for the "profile pictures only" setting. + private val shouldBridge: (profilePicture: Boolean) -> Boolean, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() + val marked = chain.request() + val kind = marked.header(MEDIA_HEADER) + val request = if (kind != null) marked.newBuilder().removeHeader(MEDIA_HEADER).build() else marked - if (!shouldBridge()) return chain.proceed(request) + if (request.method != "GET") return chain.proceed(request) + + if (isLocalCache(request.url)) { + // Already addressed to the cache (a resolved `blossom:` URI): there is no + // original URL to fall back to here, but a dead cache must still be reported. + try { + return chain.proceed(request) + } catch (e: ConnectException) { + onUnreachable() + throw e + } + } + + if (!bridges || kind == null || request.header("Authorization") != null) return chain.proceed(request) + + if (!shouldBridge(kind == PROFILE_PICTURE)) return chain.proceed(request) val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request) - return chain.proceed( - request - .newBuilder() - .url(rewritten) - .build(), - ) + keyCache?.get(request.url.toString())?.let { keyCache.add(rewritten.toString(), it) } + + val bridged = + try { + chain.proceed( + request + .newBuilder() + .url(rewritten) + .build(), + ) + } catch (e: ConnectException) { + onUnreachable() + return chain.proceed(request) + } + + if (bridged.isSuccessful) return bridged + + // The cache answered, but not with the blob: it does not hold it and could not fetch it + // from `xs` either (not every cache implements that, and the one that does can be offline, + // still warming, or rate-limited). A miss is the ordinary state of a cache and must never + // be worse than having no cache at all, so the origin is asked directly — without this the + // 404 reached the caller and the image, video or encrypted file simply failed to load. + // + // Not reported through [onUnreachable]: the cache is alive and answering, so switching the + // bridge off would be the wrong conclusion. + bridged.close() + return chain.proceed(request) } + private fun isLocalCache(url: HttpUrl): Boolean = url.port == LOCAL_CACHE_PORT && (url.host == LOCAL_CACHE_HOST || url.host.equals("localhost", ignoreCase = true)) + private fun rewriteIfApplicable(url: HttpUrl): HttpUrl? { val host = url.host if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null @@ -121,5 +185,26 @@ class LocalBlossomCacheRedirectInterceptor( const val LOCAL_CACHE_PORT = 24242 const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT" private val BLOSSOM_LAST_SEGMENT_REGEX = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$") + + /** Marks a request as a media download the local cache may serve. Stripped before sending. */ + const val MEDIA_HEADER = "X-Amethyst-Local-Blossom" + const val MEDIA = "media" + const val PROFILE_PICTURE = "profile-picture" } } + +/** + * Marks every call it creates as a media download ([LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER]), + * keeping a marker the request already carries (e.g. a profile picture's). + */ +class LocalBlossomMediaCallFactory( + private val delegate: Call.Factory, + private val kind: String = LocalBlossomCacheRedirectInterceptor.MEDIA, +) : Call.Factory { + override fun newCall(request: Request): Call = + if (request.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER) != null) { + delegate.newCall(request) + } else { + delegate.newCall(request.newBuilder().header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, kind).build()) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt index 0e9fd0892f..ea2d0233a2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpClientFactory.kt @@ -55,12 +55,13 @@ class OkHttpClientFactory( val userAgent: String, private val dns: SurgeDns, /** - * Returns `true` when sha256-keyed HTTP requests should be transparently - * rewritten to the local Blossom cache (master toggle on, profile-pictures-only - * restriction off, probe up). When `null`, the interceptor is disabled — - * useful for tests or pre-configuration call sites. + * Returns `true` when a sha256-keyed HTTP request should be transparently + * rewritten to the local Blossom cache, given whether it is a profile picture + * (master toggle on, probe up, and either not restricted to profile pictures + * or this is one). When `null`, the interceptor is disabled — useful for + * tests or pre-configuration call sites. */ - val shouldBridgeBlossomCache: (() -> Boolean)? = null, + val shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null, private val onionCache: OnionLocationCache, /** * Resource-usage ledger counter, installed OUTERMOST on the shared base @@ -76,11 +77,24 @@ class OkHttpClientFactory( * anonymous. See [BlossomReadAuthInterceptor]. */ private val blossomReadAuth: Interceptor? = null, + /** + * Called when the local Blossom cache refuses a connection, so the caller can + * mark it unavailable instead of waiting for the next periodic probe. + */ + onLocalBlossomCacheUnreachable: () -> Unit = {}, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) + + // Tor-routed media skips the local cache (the cache would fetch the origin outside Tor, and + // Tor refuses 127.0.0.1 anyway), but its media marker header must still be stripped: this + // non-bridging instance takes the redirect's place on Tor clients, and stands in for it when + // the bridge isn't configured at all. + private val blossomCacheMarkerStripper = LocalBlossomCacheRedirectInterceptor(bridges = false) { false } + private val blossomCacheRedirect = - shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(it) } + shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(keyCache, onLocalBlossomCacheUnreachable, bridges = true, shouldBridge = it) } + ?: blossomCacheMarkerStripper // Most images/videos in a feed come from a small set of hosts (e.g. a single // Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests @@ -130,15 +144,13 @@ class OkHttpClientFactory( .followSslRedirects(true) .apply { usageInterceptor?.let { addInterceptor(it) } } .addInterceptor(DefaultContentTypeInterceptor(userAgent)) - .apply { - blossomCacheRedirect?.let { addInterceptor(it) } - } - // Sits outside the network interceptors so its retry re-runs the - // full stack (content-type, blossom cache, key decryptor) for the - // authenticated response. Only signs on an actual 401. + // Sits before the local-cache redirect and outside the network interceptors, so it + // sees the origin host (a token is scoped to it) and its retry re-runs the rest of + // the stack (blossom cache, key decryptor) for the authenticated response. Only + // signs on an actual 401. A request carrying a token is never bridged. .apply { blossomReadAuth?.let { addInterceptor(it) } - } + }.addInterceptor(blossomCacheRedirect) // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) // Passively populates [onionCache] from any HTTP/WebSocket response @@ -172,7 +184,15 @@ class OkHttpClientFactory( // `.onion`s — clearnet clients must never try to resolve `.onion` // (DNS would fail, and we don't want fingerprintable lookups). .apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) } - .connectTimeout(Duration.ofSeconds(seconds.toLong())) + // The local Blossom cache lives on 127.0.0.1, which Tor refuses to reach, and it + // would fetch the origin outside Tor. Tor-routed media keeps going to its origin + // through Tor; the local cache is used by the direct client only. + .apply { + if (proxy != null) { + val index = interceptors().indexOf(blossomCacheRedirect) + if (index >= 0) interceptors()[index] = blossomCacheMarkerStripper + } + }.connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .build() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt new file mode 100644 index 0000000000..ef034b2f0f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/BackupConflictGuardTest.kt @@ -0,0 +1,427 @@ +/* + * 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.commons.model.backups + +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.signers.NostrSignerSync +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The rules that decide whether a newer version of a backed-up event may overwrite the + * backup, and how the user's answers resolve the conflicts it raises. + */ +class BackupConflictGuardTest { + private val signer = NostrSignerSync(KeyPair()) + private val local = HashSet() + private var changes = 0 + private val reapplied = ArrayList() + private val guard = + BackupConflictGuard( + isLocallySigned = { it in local }, + onChange = { changes++ }, + reapply = { reapplied.add(it) }, + ) + + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + private fun follows( + createdAt: Long, + vararg pubKeys: String, + ): Event = signer.sign(createdAt, 3, pubKeys.map { arrayOf("p", it) }.toTypedArray(), "") + + private fun signedHere(event: Event) = event.also { local.add(it.id) } + + private fun openConflict() = + guard.conflicts.value.values + .single() + + /** A backup slot the way AccountSettings drives it: the guard in front of a stored copy. */ + private inner class Backup( + var saved: Event?, + ) { + var saves = 0 + + fun update(incoming: Event) { + if (guard.accept(saved, incoming, isEmpty = incoming.tags.isEmpty()) { update(incoming) }) { + saved = incoming + saves++ + } + } + } + + @Test + fun versionsSignedHereAlwaysReplaceTheBackup() { + val backup = Backup(follows(100, alice, bob)) + val edit = signedHere(follows(200, bob)) + backup.update(edit) + assertSame(edit, backup.saved) + assertTrue(guard.conflicts.value.isEmpty()) + } + + @Test + fun externalVersionThatOnlyAddsReplacesTheBackup() { + val backup = Backup(follows(100, alice)) + val external = follows(200, alice, bob) + backup.update(external) + assertSame(external, backup.saved) + assertTrue(guard.conflicts.value.isEmpty()) + } + + @Test + fun firstVersionWithNothingSavedIsAccepted() { + val backup = Backup(null) + val external = follows(200, alice) + backup.update(external) + assertSame(external, backup.saved) + } + + @Test + fun externalLossyVersionFreezesTheBackupAndRaisesAConflict() { + val saved = follows(100, alice, bob) + val backup = Backup(saved) + val external = follows(200, bob) + backup.update(external) + + assertSame(saved, backup.saved) + val conflict = openConflict() + assertSame(saved, conflict.saved) + assertSame(external, conflict.incoming) + assertSame(external, conflict.cause) + assertEquals(backupSlot(external), conflict.slot) + } + + @Test + fun theSameVersionReEmittedKeepsTheSameConflict() { + val backup = Backup(follows(100, alice, bob)) + val external = follows(200, bob) + backup.update(external) + val first = openConflict() + backup.update(external) + assertSame(first, openConflict()) + } + + @Test + fun anOlderVersionNeverReplacesAnOpenConflict() { + val backup = Backup(follows(100, alice, bob, carol)) + val newer = follows(300, carol) + backup.update(newer) + backup.update(follows(200, bob, carol)) + assertSame(newer, openConflict().incoming) + } + + @Test + fun aNewerExternalVersionIsStillComparedWithTheFrozenBackup() { + val saved = follows(100, alice, bob) + val backup = Backup(saved) + backup.update(follows(200, bob)) + // Adds to the lossy version, but still lacks alice from the saved one. + val newer = follows(300, bob, carol) + backup.update(newer) + + val conflict = openConflict() + assertSame(saved, conflict.saved) + assertSame(newer, conflict.incoming) + assertSame(saved, backup.saved) + } + + @Test + fun aLocalEditOnTopOfTheLossyVersionKeepsTheConflictAndItsCause() { + val saved = follows(100, alice, bob) + val backup = Backup(saved) + val external = follows(200, bob) + backup.update(external) + + val edit = signedHere(follows(300, bob, carol)) + backup.update(edit) + + val conflict = openConflict() + assertSame(edit, conflict.incoming) + assertSame(external, conflict.cause) + assertSame(saved, backup.saved) + } + + @Test + fun aLocalEditThatBringsEverythingBackClearsTheConflict() { + val backup = Backup(follows(100, alice, bob)) + backup.update(follows(200, bob)) + + val edit = signedHere(follows(300, alice, bob)) + backup.update(edit) + + assertTrue(guard.conflicts.value.isEmpty()) + assertSame(edit, backup.saved) + } + + @Test + fun keepingTheNewVersionMovesTheBackupForward() { + val backup = Backup(follows(100, alice, bob)) + val external = follows(200, bob) + backup.update(external) + + guard.keepIncoming(openConflict()) + + assertTrue(guard.conflicts.value.isEmpty()) + assertSame(external, backup.saved) + } + + @Test + fun aStaleOrRepeatedAnswerDoesNothing() { + val backup = Backup(follows(100, alice, bob)) + backup.update(follows(200, bob)) + val stale = openConflict() + val newer = follows(300) + backup.update(newer) + + guard.keepIncoming(stale) + assertSame(newer, openConflict().incoming) + assertNull(guard.startRestoring(stale)) + + val current = openConflict() + guard.keepIncoming(current) + guard.keepIncoming(current) + assertEquals(1, backup.saves) + } + + @Test + fun theRetryOfAnOvertakenAcceptCannotClearTheNewerConflict() { + val backup = Backup(follows(100, alice, bob, carol)) + val first = follows(200, bob, carol) + backup.update(first) + val claimed = openConflict() + + // A newer lossy change arrives, then the retry of the (stale) first one runs. + val newer = follows(300, carol) + backup.update(newer) + guard.keepIncoming(claimed) + backup.update(first) + + assertSame(newer, openConflict().incoming) + } + + @Test + fun whileRestoringTheLossyVersionCannotReRaiseTheConflict() { + val backup = Backup(follows(100, alice, bob)) + val external = follows(200, bob) + backup.update(external) + val conflict = openConflict() + + val token = assertNotNull(guard.startRestoring(conflict)) + backup.update(external) + assertTrue(guard.conflicts.value.isEmpty()) + + guard.finishRestoring(conflict, token, restored = true) + assertTrue(guard.conflicts.value.isEmpty()) + } + + @Test + fun aFailedRestoreReopensTheConflict() { + val backup = Backup(follows(100, alice, bob)) + val external = follows(200, bob) + backup.update(external) + val conflict = openConflict() + + val token = assertNotNull(guard.startRestoring(conflict)) + guard.finishRestoring(conflict, token, restored = false) + assertSame(conflict, openConflict()) + + // The reopened conflict can still be answered, and its retry still works. + guard.keepIncoming(conflict) + assertSame(external, backup.saved) + } + + @Test + fun aFailedRestoreDoesNotOverwriteANewerConflict() { + val backup = Backup(follows(100, alice, bob, carol)) + backup.update(follows(200, bob, carol)) + val conflict = openConflict() + + val token = assertNotNull(guard.startRestoring(conflict)) + val newer = follows(300, carol) + backup.update(newer) + guard.finishRestoring(conflict, token, restored = false) + + assertSame(newer, openConflict().incoming) + } + + @Test + fun anExternalWipeIsQuestioned() { + val saved = follows(100, alice) + val backup = Backup(saved) + backup.update(follows(200)) + assertSame(saved, backup.saved) + assertNotNull(openConflict()) + } + + @Test + fun anExternalWipeOfAnEmptyBackupIsNotStored() { + val saved = follows(100) + val backup = Backup(saved) + backup.update(follows(200)) + assertSame(saved, backup.saved) + assertTrue(guard.conflicts.value.isEmpty()) + } + + @Test + fun aWipeSignedHereOrKeptByTheUserIsStored() { + val backup = Backup(follows(100, alice)) + val wipe = signedHere(follows(200)) + backup.update(wipe) + assertSame(wipe, backup.saved) + + val other = Backup(follows(100, alice)) + // A different timestamp, or it would be the very event signed above. + val externalWipe = follows(201) + other.update(externalWipe) + guard.keepIncoming(openConflict()) + assertSame(externalWipe, other.saved) + } + + @Test + fun droppingASlotForgetsItsConflict() { + val backup = Backup(follows(100, alice, bob)) + backup.update(follows(200, bob)) + val conflict = openConflict() + + guard.drop(conflict.slot) + assertTrue(guard.conflicts.value.isEmpty()) + guard.keepIncoming(conflict) + assertEquals(0, backup.saves) + } + + @Test + fun eachEventKindHasItsOwnSlot() { + val followsBackup = Backup(follows(100, alice, bob)) + val mutes = Backup(signer.sign(100, 10000, arrayOf(arrayOf("p", alice)), "")) + followsBackup.update(follows(200, bob)) + mutes.update(signer.sign(200, 10000, arrayOf(), "")) + assertEquals(2, guard.conflicts.value.size) + } + + @Test + fun openConflictsRoundTripThroughStorage() { + val saved = follows(100, alice, bob) + val external = follows(200, bob) + Backup(saved).update(external) + + val afterRestart = BackupConflictGuard(isLocallySigned = { false }) + afterRestart.restore(guard.open()) + + val conflict = + afterRestart.conflicts.value.values + .single() + assertEquals(saved.id, conflict.saved.id) + assertEquals(external.id, conflict.incoming.id) + assertEquals(external.id, conflict.cause.id) + } + + @Test + fun aStoredConflictThatNoLongerLosesDataIsNotRestored() { + val saved = follows(100, alice) + val putBack = follows(200, alice, bob) + guard.restore(listOf(Triple(saved, putBack, putBack))) + assertTrue(guard.conflicts.value.isEmpty()) + } + + @Test + fun keepingARestoredConflictReappliesItsVersion() { + val external = follows(200, bob) + guard.restore(listOf(Triple(follows(100, alice, bob), external, external))) + + guard.keepIncoming(openConflict()) + + assertEquals(listOf(external), reapplied) + // The reapplied update is now accepted instead of raising the conflict again. + val backup = Backup(follows(100, alice, bob)) + backup.update(external) + assertSame(external, backup.saved) + } + + @Test + fun aReEmittedVersionGivesARestoredConflictItsRetryBack() { + val saved = follows(100, alice, bob) + val external = follows(200, bob) + guard.restore(listOf(Triple(saved, external, external))) + + val backup = Backup(saved) + backup.update(external) + guard.keepIncoming(openConflict()) + + assertTrue(reapplied.isEmpty()) + assertSame(external, backup.saved) + } + + @Test + fun everyChangeToTheOpenConflictsIsReported() { + val backup = Backup(follows(100, alice, bob)) + val external = follows(200, bob) + backup.update(external) + assertEquals(1, changes) + + // A re-emit changes nothing to store. + backup.update(external) + assertEquals(1, changes) + + val conflict = openConflict() + val token = assertNotNull(guard.startRestoring(conflict)) + assertEquals(2, changes) + guard.finishRestoring(conflict, token, restored = false) + assertEquals(3, changes) + + guard.drop(conflict.slot) + assertEquals(4, changes) + guard.drop(conflict.slot) + assertEquals(4, changes) + } + + @Test + fun anOlderVersionNeverRollsTheBackupBack() { + val saved = follows(200, alice) + val backup = Backup(saved) + backup.update(signedHere(follows(100, alice, bob))) + assertSame(saved, backup.saved) + } + + @Test + fun aFailedRestoreDoesNotReopenAConflictTheBackupMovedPast() { + val saved = follows(100, alice, bob) + val backup = Backup(saved) + backup.update(follows(200, bob)) + val conflict = openConflict() + val token = assertNotNull(guard.startRestoring(conflict)) + + // While the signer is still asking, another app puts everything back and more. + val fixed = follows(300, alice, bob, carol) + backup.update(fixed) + guard.finishRestoring(conflict, token, restored = false) + + assertTrue(guard.conflicts.value.isEmpty()) + assertSame(fixed, backup.saved) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt new file mode 100644 index 0000000000..ece67f29b7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupDiffTest.kt @@ -0,0 +1,121 @@ +/* + * 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.commons.model.backups + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The backup guard's use of the events' own diffs; the diffs themselves are tested in quartz. */ +class ReplaceableBackupDiffTest { + private val signer = NostrSignerSync(KeyPair()) + + private fun sign( + kind: Int, + createdAt: Long, + tags: Array>, + content: String = "", + ): Event = signer.sign(createdAt, kind, tags, content) + + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + + @Test + fun lossyRewriteIsDetected() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + val incoming = sign(3, 200, arrayOf(arrayOf("p", bob))) + val diff = assertIs(ReplaceableBackupDiff.detectLoss(saved, incoming)) + assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey }) + } + + @Test + fun additionsOnlyAreNotALoss() { + val saved = sign(3, 100, arrayOf(arrayOf("p", alice))) + val incoming = sign(3, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob))) + assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + } + + @Test + fun clearedPrivateItemsAreALoss() { + val saved = sign(10000, 100, arrayOf(), "encrypted") + val incoming = sign(10000, 200, arrayOf(), "") + assertNotNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + } + + @Test + fun olderVersionIsNeverALoss() { + val saved = sign(3, 200, arrayOf(arrayOf("p", alice))) + val incoming = sign(3, 100, arrayOf()) + assertNull(ReplaceableBackupDiff.detectLoss(saved, incoming)) + } + + @Test + fun locallySignedEventsTrackOnlyReplaceables() { + val list = sign(10000, 100, arrayOf()) + val note = sign(1, 100, arrayOf()) + LocallySignedEvents.mark(list) + LocallySignedEvents.mark(note) + assertTrue(LocallySignedEvents.contains(list.id)) + assertFalse(LocallySignedEvents.contains(note.id)) + } + + @Test + fun locallySignedEventsForgetTheOldestBeyondCapacity() { + val first = sign(10000, 1, arrayOf()) + LocallySignedEvents.mark(first) + repeat(600) { LocallySignedEvents.mark(sign(10000, 2L + it, arrayOf())) } + assertFalse(LocallySignedEvents.contains(first.id)) + } + + @Test + fun restoreDropsClientPowAndPastExpiration() { + val saved = + sign( + 10000, + 100, + arrayOf( + arrayOf("p", alice), + arrayOf("client", "OtherApp"), + arrayOf("nonce", "123", "20"), + arrayOf("expiration", "500"), + arrayOf("word", "spam"), + ), + "private", + ) + val tags = BackupRestore.tagsToResign(saved, now = 1000) + assertEquals(listOf(listOf("p", alice), listOf("word", "spam")), tags.map { it.toList() }) + } + + @Test + fun restoreKeepsAFutureExpiration() { + val saved = sign(10000, 100, arrayOf(arrayOf("p", alice), arrayOf("expiration", "5000"))) + val tags = BackupRestore.tagsToResign(saved, now = 1000) + assertEquals(listOf(listOf("p", alice), listOf("expiration", "5000")), tags.map { it.toList() }) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectSafetyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectSafetyTest.kt new file mode 100644 index 0000000000..0468b4d4eb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheRedirectSafetyTest.kt @@ -0,0 +1,295 @@ +/* + * 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.commons.service.http + +import com.vitorpamplona.quartz.utils.ciphers.NostrCipher +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.lang.reflect.Proxy +import java.net.ConnectException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class LocalBlossomCacheRedirectSafetyTest { + private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + private val origin = "https://blossom.example.com/$sha.jpg" + private val bridged = "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com" + + private fun media( + url: String = origin, + kind: String = LocalBlossomCacheRedirectInterceptor.MEDIA, + ) = Request.Builder().url(url).header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER, kind) + + private object IdentityCipher : NostrCipher { + override fun name() = "identity" + + override fun encrypt(bytesToEncrypt: ByteArray) = bytesToEncrypt + + override fun decrypt(bytesToDecrypt: ByteArray) = bytesToDecrypt + + override fun decryptOrNull(bytesToDecrypt: ByteArray) = bytesToDecrypt + } + + /** Records every request passed to proceed; [refuse] makes matching ones fail like a closed port. */ + private fun chain( + request: Request, + sent: MutableList, + statusFor: (Request) -> Int = { 200 }, + refuse: (Request) -> Boolean = { false }, + ): Interceptor.Chain = + Proxy.newProxyInstance( + Interceptor.Chain::class.java.classLoader, + arrayOf(Interceptor.Chain::class.java), + ) { _, method, args -> + when (method.name) { + "request" -> request + "proceed" -> { + val proceeded = args[0] as Request + sent.add(proceeded) + if (refuse(proceeded)) throw ConnectException("Connection refused") + val status = statusFor(proceeded) + Response + .Builder() + .request(proceeded) + .protocol(Protocol.HTTP_1_1) + .code(status) + .message(if (status == 200) "OK" else "Not Found") + .body("".toResponseBody(null)) + .build() + } + else -> throw UnsupportedOperationException(method.name) + } + } as Interceptor.Chain + + @Test + fun getIsBridged() { + val sent = mutableListOf() + LocalBlossomCacheRedirectInterceptor { true }.intercept(chain(media().build(), sent)).close() + assertEquals(bridged, sent.single().url.toString()) + } + + @Test + fun profilePicturesOnlyBridgesProfilePicturesAlone() { + // The "profile pictures only" setting: bridge a request only when it is a profile picture. + val interceptor = LocalBlossomCacheRedirectInterceptor { profilePicture -> profilePicture } + + val feedImage = mutableListOf() + interceptor.intercept(chain(media().build(), feedImage)).close() + assertEquals(origin, feedImage.single().url.toString()) + + val avatar = mutableListOf() + interceptor.intercept(chain(media(kind = LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE).build(), avatar)).close() + assertEquals(bridged, avatar.single().url.toString()) + } + + @Test + fun unmarkedRequestsAreNeverBridged() { + // BUD-02 `GET /list/` ends in 64 hex chars too, but it is not a blob download. + val list = "https://blossom.example.com/list/$sha" + val sent = mutableListOf() + LocalBlossomCacheRedirectInterceptor { true } + .intercept( + chain( + Request + .Builder() + .url(list) + .header("Authorization", "Nostr abc") + .build(), + sent, + ), + ).close() + assertEquals(list, sent.single().url.toString()) + } + + @Test + fun authGatedDownloadsGoToTheirServer() { + val sent = mutableListOf() + LocalBlossomCacheRedirectInterceptor { true } + .intercept(chain(media().header("Authorization", "Nostr abc").build(), sent)) + .close() + assertEquals(origin, sent.single().url.toString()) + } + + @Test + fun theMarkerNeverLeavesTheApp() { + listOf( + LocalBlossomCacheRedirectInterceptor { true }, + LocalBlossomCacheRedirectInterceptor { false }, + LocalBlossomCacheRedirectInterceptor(bridges = false) { true }, + ).forEach { interceptor -> + val sent = mutableListOf() + interceptor.intercept(chain(media().build(), sent)).close() + assertNull(sent.single().header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER)) + } + } + + @Test + fun aNonBridgingInstanceLeavesMediaAlone() { + val sent = mutableListOf() + LocalBlossomCacheRedirectInterceptor(bridges = false) { true }.intercept(chain(media().build(), sent)).close() + assertEquals(origin, sent.single().url.toString()) + } + + @Test + fun mediaCallFactoryMarksCallsButKeepsAnExistingMarker() { + val seen = mutableListOf() + val factory = + LocalBlossomMediaCallFactory({ request -> + seen.add(request) + OkHttpClient().newCall(request) + }) + + factory.newCall(Request.Builder().url(origin).build()) + factory.newCall(media(kind = LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE).build()) + + assertEquals( + listOf(LocalBlossomCacheRedirectInterceptor.MEDIA, LocalBlossomCacheRedirectInterceptor.PROFILE_PICTURE), + seen.map { it.header(LocalBlossomCacheRedirectInterceptor.MEDIA_HEADER) }, + ) + } + + @Test + fun serverSpecificMethodsAreNotBridged() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val requests = + listOf( + Request + .Builder() + .url(origin) + .head() + .build(), + Request + .Builder() + .url(origin) + .delete() + .header("Authorization", "Nostr abc") + .build(), + Request + .Builder() + .url(origin) + .put("x".toRequestBody()) + .build(), + ) + requests.forEach { request -> + val sent = mutableListOf() + interceptor.intercept(chain(request, sent)).close() + assertEquals(origin, sent.single().url.toString(), "${request.method} must reach its server") + } + } + + @Test + fun decryptionKeyFollowsTheRewrite() { + val keys = EncryptionKeyCache() + val info = DecryptInformation(IdentityCipher, "image/jpeg") + keys.add(origin, info) + + val sent = mutableListOf() + LocalBlossomCacheRedirectInterceptor(keyCache = keys) { true }.intercept(chain(media().build(), sent)).close() + + // EncryptedBlobInterceptor runs after the rewrite and looks the key up by the URL it sees. + assertSame(info, assertNotNull(keys.get(sent.single().url.toString()))) + } + + @Test + fun deadCacheFallsBackToOriginAndIsReported() { + var reports = 0 + val sent = mutableListOf() + val response = + LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true } + .intercept(chain(media().build(), sent) { it.url.host == "127.0.0.1" }) + + assertEquals(200, response.code) + assertEquals(listOf(bridged, origin), sent.map { it.url.toString() }) + assertEquals(1, reports) + response.close() + } + + @Test + fun deadCacheIsReportedForResolvedBlossomUrls() { + var reports = 0 + val sent = mutableListOf() + val request = media("http://127.0.0.1:24242/$sha.mp4?xs=https://blossom.example.com").build() + + assertFailsWith { + LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true } + .intercept(chain(request, sent) { true }) + } + assertEquals(1, reports) + assertTrue(sent.single().url.host == "127.0.0.1") + } + + @Test + fun aCacheMissFallsBackToTheOrigin() { + var reports = 0 + val sent = mutableListOf() + + val response = + LocalBlossomCacheRedirectInterceptor(onUnreachable = { reports++ }) { true } + .intercept( + chain(media().build(), sent, statusFor = { if (it.url.host == "127.0.0.1") 404 else 200 }), + ) + + // The blob still arrives: the cache is an optimisation, never a gate. + assertEquals(200, response.code) + assertEquals(origin, response.request.url.toString()) + assertEquals(listOf(bridged, origin), sent.map { it.url.toString() }) + // The cache answered, so it is alive — the bridge must not be switched off. + assertEquals(0, reports) + response.close() + } + + @Test + fun aCacheErrorFallsBackToTheOriginToo() { + val sent = mutableListOf() + + val response = + LocalBlossomCacheRedirectInterceptor { true } + .intercept( + chain(media().build(), sent, statusFor = { if (it.url.host == "127.0.0.1") 500 else 200 }), + ) + + assertEquals(200, response.code) + assertEquals(listOf(bridged, origin), sent.map { it.url.toString() }) + response.close() + } + + @Test + fun aServedBlobIsNotRefetchedFromTheOrigin() { + val sent = mutableListOf() + + val response = LocalBlossomCacheRedirectInterceptor { true }.intercept(chain(media().build(), sent)) + + assertEquals(200, response.code) + // A hit must cost exactly one request, to the cache. + assertEquals(listOf(bridged), sent.map { it.url.toString() }) + response.close() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheTorRoutingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheTorRoutingTest.kt new file mode 100644 index 0000000000..342f8e1954 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/LocalBlossomCacheTorRoutingTest.kt @@ -0,0 +1,115 @@ +/* + * 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.commons.service.http + +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.Request +import java.net.ServerSocket +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * With "videos via Tor" on, the player pool is built with a Tor-proxied [DynamicCallFactory], but a + * `blossom:` video resolves to the local Blossom cache on 127.0.0.1 only when the data source opens. + * Tor refuses loopback addresses, so the local cache must be reached directly or no video plays. + */ +class LocalBlossomCacheTorRoutingTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @AfterTest + fun tearDown() = scope.cancel() + + private fun manager(proxyPort: Int) = + DualHttpClientManager( + userAgent = "test", + proxyPortProvider = MutableStateFlow(proxyPort), + isMobileDataProvider = MutableStateFlow(false), + keyCache = EncryptionKeyCache(), + scope = scope, + dns = SurgeDns(), + shouldBridgeBlossomCache = { true }, + onionCache = OnionLocationCache(), + ) + + /** A port nothing listens on: stands in for a SOCKS proxy that cannot reach the target. */ + private fun deadPort(): Int = ServerSocket(0).use { it.localPort } + + /** Minimal one-shot HTTP server standing in for the local Blossom cache. */ + private fun localCache(body: String): ServerSocket { + val server = ServerSocket(0) + thread(isDaemon = true) { + server.accept().use { socket -> + // Drain the request headers before answering. + val reader = socket.getInputStream().bufferedReader() + do { + val line = reader.readLine() + } while (!line.isNullOrEmpty()) + socket.getOutputStream().write( + "HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body".toByteArray(), + ) + } + } + return server + } + + @Test + fun proxiedFactoryReachesLoopbackCacheDirectly() { + val cache = localCache("video-bytes") + val factory = manager(deadPort()).getDynamicCallFactory(useProxy = true) + + val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + val request = Request.Builder().url("http://127.0.0.1:${cache.localPort}/$sha.mp4?xs=https://blossom.example.com").build() + + factory.newCall(request).execute().use { response -> + assertEquals(200, response.code) + assertEquals("video-bytes", response.body.string()) + } + cache.close() + } + + @Test + fun proxyDecisionUsesTheFinalRequestUrl() { + assertFalse(DynamicCallFactory.shouldUseProxy(true, "http://127.0.0.1:24242/abc.mp4?xs=https://blossom.primal.net")) + assertFalse(DynamicCallFactory.shouldUseProxy(true, "http://localhost:24242/abc.mp4")) + assertTrue(DynamicCallFactory.shouldUseProxy(true, "https://blossom.primal.net/abc.mp4")) + assertFalse(DynamicCallFactory.shouldUseProxy(false, "https://blossom.primal.net/abc.mp4")) + } + + @Test + fun onlyTheDirectClientRewritesToTheLocalCache() { + val manager = manager(deadPort()) + val direct = manager.getHttpClient(useProxy = false) + val proxied = manager.getHttpClient(useProxy = true) + + // Both carry one (it strips the media marker), but only the direct client's bridges. + assertEquals(listOf(true), direct.interceptors.filterIsInstance().map { it.bridges }) + assertEquals(listOf(false), proxied.interceptors.filterIsInstance().map { it.bridges }) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 8e11546ff3..df7a1c5d77 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -166,6 +166,147 @@ यातायात अचलता मार्ग घटना + भूकोष + पारम्परिक + बहुकोष + रहस्य + सूक्ष्म + लघु + सामान्य + बृहत + अन्य आकार + कठिनाई%1$d + धरा%1$d + खोज में पहला + कलाकृति + मुख्य खोज + सत्यापित खोज + कथित + संगृहीत + क्या आप अटक गए। दबाएँ सहायक सूचना देखने के लिए + क्या पठनशक्य नहीं। दबाएँ अतिरिक्त पठन के लिए + निकट + प्राप्त। + सत्यापित खोज + प्रमाण ठीक नहीं + कोष आवहन… + छिपाया गया %1$s द्वारा + मार्गदर्शन + निर्देशांकों की अनुकृति + निर्देशांक अनुकृत + चित्र + मुख्य खोज अभियान + अभिलेख + इस कोष का अभिलेखन अब तक किसी ने नहीं किया। सर्वप्रथम बनें। + मैं ने खोज लिया + खोज असफल + टीका जोडें + देखरेख आवश्यक वृत्तान्त + खोज में जोडें + नई खोज… + आपने कोई खोज नहीं की अब तक। + पहले से इस खोज में लगे + %1$d कोष यहाँ + इस चित्र को हटाएँ + इस कोष को संगृहीत कर दिया गया। यह अब खेल में नहीं है। + सर्वप्रथम खोज का कथन %1$s द्वारा + सर्वप्रथम खोज अभी भी सम्भव + स्वत्वधारी + कोष सम्पादन + कोष को संगृहीत करें + संगृहीत करें। खोजकर्ता देखेंगे कि यह खेल के बाहर हो गया है। तथा सूचीकृत विवरण पठनशक्य रहेगा। + सर्वप्रथम खोजक तालाबद्ध करें + एक एफ सूचक का प्रकाशन करता है %1$s का नाम लेकर। यह स्थायी है। + कोई सत्यापित खोज नहीं अब तक। इसलिए तालाबद्ध करने के लिए कोई नहीं। + सत्यापन अक्षरराशि दिखाएँ + इसे छाप कर कोष में रखें। अमेथिस्ट इसको नहीं रखेगा। यदि आप इसे खो देते हैं तो खोजक प्रमाणित नहीं कर पाएँगे कि वे वहाँ थे। + खोज असफल + टीका + देखरेख आवश्यक + खोज का अभिलेखन + कोष + आपका अभिलेख + कैसे चला। + चित्र जोडें + आपका मुख्य खोज उत्तर + उपस्थिति प्रमाण + कोष में अक्षरराशि परखें + एक अन्य अक्षरराशि परखें + सत्यापित। आप कोष के स्थान पर उपस्थित थे + हस्ताक्षरित एक बार पेटीका में प्राप्त अक्षरराशि से। कुंचिका का अभिलेखन कभी भी नहीं किया जाता तथा इस पटल के बाहर नहीं जाता। + वह एक सत्यापन अक्षरराशि नहीं। + वह अक्षरराशि एक पृथक कोष का है। + यह कोष प्रमाण नहीं पूछता। इसलिए आप सीधे इसका अभिलेखन कर सकते हैं। + आपका स्थान इस अभिलेख में संलग्न नहीं। + पत्र प्रकाशन + अभिलेख जोडें + निकट + मानचित्र + खोज + सफल खोज + मेरा + अप्राप्त + कठिनाई %1$d समन्यूनवा + धरा %1$d समन्यूनवा + पुरस्कारयुक्त + प्रमाण आवश्यक + मेरे स्थान का उपयोग करें + दूरी अनुसार क्रमबद्ध + नवीनतम पहले + कोष को छिपाएँ + कोई कोष नहीं यहाँ अब तक। अधिक विस्तृत अनुगमन सूची का प्रयास करें। अथवा प्रथम को छिपाएँ। + आपने किसी खोज का अभिलेखन नहीं किया अब तक। + आपने किसी कोष को छिपाया नहीं अब तक। + कोई खोज नहीं अब तक। + सफल खोज + सर्वप्रथम खोज + सत्यापित + छिपा हुआ + मुझ पर मध्यीकरण + मानचित्र पर दीर्घ दबाएँ वहाँ एक कोष छिपाने के लिए + खोलें + %1$d कोष + कहाँ + क्या + अतिरिक्त + प्रमाण + पुनःप्रसारक + यह एक सार्वजनिक स्थान प्रकाशित करता है जो वास्तविक से पाँच मीटर व्यासार्ध तक की विस्थापन सम्भावना रखता है। नोस्टर पर कोई भी इसे देख सकता है। प्रकाशित घटना को पुनःअप्रकाशित नहीं किया जा सकता। + स्थल का चयन करें + प्रकाशित करता है %1$s को। तथा स्थूलतर विभाग तीन अक्षरों तक निकटवर्ती खोज के लिए। + नाम + विवरण + प्रकार + आकार + कठिनाई + धरा + सहायक संकेत + खोजक देखेंगे + सहायक संकेत को अस्तव्यस्त करें (रोट॰तेरह) + मुख्य खोज अभियान + संशोधक + उपस्थिति प्रमाण आवश्यक + एक सत्यापन कुंचिका उत्पादित इस कोष के लिए। + खोजक कहाँ अभिलेखन करना चाहिए + प्रकाशन + कोष नामकरण। + कोष कहाँ छिपाया जाए चयन करें। + %2$d में से %1$d प्राप्त + खोज आरम्भ करें + %1$d कोष + शीर्षक + विवरण + ध्वज चित्र + कोष क्रमानुसार + कोष जोडें + ऊर्ध्वगमन + अधोगमन + हटाएँ + पृष्ठ शैली + मानचित्र शैली + खोज का एक शीर्षक रखें। + न्यूनतम एक कोष जोडें। + प्रकाशन असफल। पुनःप्रसारक संयोजन का जाँच करें तथा पुनःप्रयास करें। इसको ज्साप इनमें विभाजित : %1$d प्रतिशतप्रतिशत मूल्य के लिए मूल्य @@ -214,6 +355,18 @@ यह एक जाल योजक है। यह साधारण लेख है। नोस्टर सम्बन्धित अक्षरराशि नहीं। यह नोस्टर सम्बन्धित अक्षरराशि है। पर इस पटल द्वारा नहीं खोला जा सकता। + परिचय + टीका + नोस्टर अक्षरराशि + पुनःप्रसारक + निजी कुंचिका + धनकोष संयोजन + दूरस्थ हस्ताक्षरकर्ता पता + हस्ताक्षरकर्ता संयोजन अनुरोध + लैटनिंग चालान + काशयू अक्षरराशि + जाल योजक + साधारण लेख योजक खोलें अनुकृति अनुकृत @@ -675,14 +828,27 @@ घण्टे मिनुट सेकण्ड + पहले ही अभिलेखित व्यायामों का उपयोग करें + स्वास्थ्य संयोजन (सामसंग स्वास्थ्य गूगिल फिट फिटबिट गार्मिन इत्यादि) से जोडें मेरा स्वास्थ्य पटल का निर्माण करने के लिए तथा आपका अपना व्यायाम साराम्श तथा इस व्यायाम को आपकी घडी के अभिलेखों से भरने के लिए प्रत्येक संख्या को हाथ से लिखने के स्थान पर। संयोजन %1$s सहस्रमीटर स्वास्थ्य संयोजन से अमेथिस्ट क्या पढता है स्वास्थ्य संयोजन तथा अमेथिस्ट + अमेथिस्ट स्वास्थ्य संयोजन का उपयोग करता है मेरा स्वास्थ्य पटल का निर्माण करने के लिए। आपका अपना अभ्यास साराम्श आपके अपने संचारयन्त्र पर। + मेरा स्वास्थ्य (विकल्पसूची से आप से मेरा स्वास्थ्य) आपको दिखाता है आप कैसे अभ्यास कर रहे हैं। इस सप्ताह कितना किया पूर्व सप्ताह की तुलना में। आपका समय कैसे बाँटा गया धवन द्विचक्रचालन चलन तरण तथा व्यायामशाला में। आपके सर्वोत्तम प्रयास। कितने दिन आपने अभ्यास किया। तथा आपकी वर्तमान निरन्तर अभ्यास दिनों की स्थिति। अमेथिस्ट इस चित्र का निर्माण करता है उन व्यायामों से जिन्हें आपकी घडी ने अथवा स्वास्थ्य क्रमक ने पहले से स्वास्थ्य संयोजन में अभिलेखित किए हैं। तथा अन्य व्यायाम जो आपने सीधे अमेथिस्ट में अभिलेखित किए। इसका परिकलन आपके संचारयन्त्र पर किया जाता है तथा आपको दिखाया जाता है। अमेथिस्ट क्या पढता है और क्यों + व्यायाम। आप ने कौनसी क्रिया की। वह कब आरम्भ हुआ तथा कितने समय तक चला। यह व्यायाम स्वयम है। यह चलाता है आपकी व्यायाम गिनती आपका अभ्यास समय प्रत्येक क्रिया का विश्लेशन आपके सक्रिय दिन तथा निरन्तर अभ्यास दिन। + दूरी। आप कितने दूर गए। प्रदर्शित इस सप्ताह की दूरी पूर्व सप्ताह की तुलना में। आपका साप्ताहिक मध्यमान। दूरी प्रति क्रिया अनुसार। तथा सर्वोत्तम दूरी। + सक्रिय तथा कुल उष्ममानइकाई। ऊर्जा जिसका व्यय व्यायाम से किया गया। प्रदर्शित आपकी ऊर्जा इस सप्ताह के रूप में तथा पूर्व सप्ताह की तुलना में। सक्रिय उष्ममानइकाई उपयुक्त हैं जब आपके स्रोत उनका अभिलेखन करते हैं। कुल उष्ममानइकाई विकल्पतः उपयुक्त उन स्रोतों के लिए जो केवल कुल ऊर्जा का अभिलेखन करते हैं। + हृदयस्पन्दन गति। मध्यमान तथा उच्चतम हृदयस्पन्दन गति व्यायाम के समय। तथा उस कालवधि का मध्यमान तथा उच्चतम हृदयस्पन्दन गति में संयुक्त। इसी से आप बता सकते हैं एक सरल सप्ताह और कठिन सप्ताह में पृथकता समान दूरी पर। + पद। पदों की गणना एक धवन चलन अथवा पदयात्रा की। प्रदर्शित आपका साप्ताहिक पद मध्यमान तथा आपकी उच्चतम पद गिनती के रूप में। + ऊँचाई वृद्धि। आप कितना ऊपर चढे। प्रदर्शित आपका साप्ताहिक आरोहण मध्यमान तथा अपका बृहत्तम आरोहण के रूप में। यही है पृथकता एक समतल सप्ताह का एक पर्वतीय से। अमेथिस्ट क्या नहीं करता + केवल पिछले चार सप्ताहों के व्यायामों को पढता है। तथा तब ही जब मेरा स्वास्थ्य अथवा व्यायाम सम्पादक खुला है। पृष्ठभूत होकर कभी पढता नही। स्वास्थ्य संयोजन पर कुछ भी लिखता नहीं। तथा आपको कभी भी नहीं पूछता आपका व्यायाम मार्ग अथवा स्थल अथवा अन्य किसी भी स्वास्थ्य जानकारी प्रकार के विषय में। + आपकी स्वास्थ्य जानकारी कहीं अन्यत्र नहीं भेजता। मेरा स्वास्थ्य का परिकलन आपके संचारयन्त्र पर किया जाता है तथा वहीं रहता है। यदि आगे आप एक व्यायाम को प्रकाशित करना चाहते हैं तो उस व्यायाम में बाँटें पर दबाएँ। अमेथिस्ट ने जो भरा उसे पढें। तथा स्वयम प्रकाशित करें। एक व्यायाम प्रत्येक बार। स्वचालित कभी नहीं। + यह सम्पूर्ण सुविधा विकल्पात्मक है। अनुमतियों का निराकरण करें स्वास्थ्य संयोजन में कभी भी। मेरा स्वास्थ्य अपनी आज्ञा अनुरोध अवस्था तक लौट जाएगा। अथवा स्थापना विकल्पों में सम्पादन पटल में सम्पादक सुझाव निष्क्रिय करें। अमेथिस्ट के शेष भाग कार्य करते रहेंगे। सम्पूर्ण गोपनीयता नीति पढें मेरा स्वास्थ्य पिछले चार सप्ताह @@ -721,7 +887,7 @@ संयोजन व्यायाम बाँटें स्पन्दनप्रतिमिनुट - सहस्रउष्ममानइकाई + सहस्रउष्म॰इ सहस्रमीटर मील मीटर @@ -1161,6 +1327,8 @@ मतदान समापन दिनांक तथा समय मतदान %1$s में समाप्त होगा घटना %1$s के लिए खोज चल रहा है + आवहन चालू + आपके निजी सन्देशों का आवहन की प्रतीक्षा… ज्साप भेजें सार्वजनिक संदेश जोडें निजी संदेश जोडें @@ -1436,6 +1604,14 @@ उपयोगकर्ता आद्यताएँ खोज स्थापना विकल्प "%1$s" के लिए कोई स्थापना विकल्प प्राप्त नहीं + समूह अद्यतन किया गया + संलग्न अभिलेख + सन्देश + %1$s : %2$s + समूह बनाएँ + मार्मोट आमन्त्रण अन्य यन्त्र तक जा रहे हैं तथा यहाँ नहीं खोले जा सकते। + हटाएँ + इस यन्त्र से प्रकाशन क्या मार्मोट॰ स्थिति पुनःस्थापित करें। यह स्थायी रूप से सभी मार्मोट॰ समूह चर्चा तथा सन्देश इतिहास तथा एमएलएस॰ कुंचिका मिटा देगा इस यन्त्र पर वर्तमान लेखा के लिए। समकक्षजन को सूचित नहीं किया जाएगा तथा सम्भाव्यतः आपको समूहों मे देखते रहेंगे उनके अगले स्थायी अभिलेखन तक। इसे पूर्ववत नहीं किया जा सकता। एक नयी कुंचिकापोटली प्रकाशित की जाएगी अगली बार जब क्रमक समचरणीकरण किया जाएगा। पुनःस्थापन @@ -2025,6 +2201,8 @@ चलचित्र दिखाएँ तैरते गवाक्ष में (अदृश्य यदि अनालम्बित) यन्त्र पर निक्षेपण चलचित्र को क्रेमकास्ट॰ ग्राहक तक भेजें आपके वैफै॰ पर (स्थानीय अभिलेखों के लिए अदृष्ट) + अवबोधक + उपशीर्षक सक्रिय अथवा निष्क्रिय करें। अदृश्य जब चलचित्र में उपलब्ध नहीं ध्वनि के स्वर बजते समय प्रदर्शनीय चलनाकृति का चयन करें। निष्क्रिय वर्णक्रम पट्टियाँ @@ -3003,13 +3181,18 @@ आपको सूचित करता है जब किसी ने आपके पत्र को उत्तर लिखा हो प्रतिवचन नए उत्तर + नहीं भेजा गया उत्तर + पुनःप्रयास + भेजा जा रहा है… + अभी भी भेजा जा रहा है… आपको सूचित करता है जब कोई आपके पत्र को पुनःप्रकाशित करते हैं %1$s ने आपके पत्र को पुनःप्रकाशित किया पुनःप्रकाशन नए पुनःप्रकाशन सूचनाएँ जब एक समयनिर्धारित पत्र का प्रकाशन होता है अथवा प्रकाशन असफल। प्रकाशन समय निर्धारित पत्र + इस लेखा को अमेथिस्ट से हटाया गया था। सूचित करता है जब किसी ने आपको ज्साप किया %1$s साट्स %1$s के लिए @@ -3210,6 +3393,10 @@ मौन हटाएँ प्रत्याह्वान जालपता उपलब्ध नहीं %1$s के उत्तर से निरस्त करें + निष्क्रिय + अवबोधक निष्क्रिय करें + अवबोधक सक्रिय करें + अवबोधक काशयू अक्षरराशि टकसाल का जाँच… काशयू नहीं चुका पाए @@ -3695,10 +3882,12 @@ साहसिक प्रस्तावना साहसिक पठन साहसिक दृश्य + क्रमक सूची क्रमक अनुशंसाएँ समयनियुक्ति क्रमक समयनियुक्ति प्रत्युत्तर अपेक्षित + लेख सूची ध्वनि शीर्षक ध्वनि अभिलेख कृत पुटप्रसार @@ -3711,6 +3900,7 @@ ब्लोस्सम॰ प्रमाणीकरण ब्लोस्सम॰ प्रसारसंगणक स्मर्त्तव्य सूची + स्मर्त्तव्य सूची प्रसारण पुनःप्रसारक दिनदर्शिका प्रणाली परिभाषा @@ -3748,6 +3938,7 @@ भावचिह्न पोटलियाँ अनभिलिखित चर्चा अस्थायी चर्चाशालाएँ + इष्ट सूचनावलियाँ प्रिय पुटप्रसार इष्ट पुनःप्रसारक अभिलेख शीर्षक @@ -3756,28 +3947,36 @@ अनुचरण पोटलियाँ भूभागसूचक अनुचरित उपहारकोष + गिट लेखक गिट समस्या गिट परिहार कर्षण अनुरोध कर्षण अनुरोध अद्यतन गिट उत्तर गिट क्रमलेखकोष + गिट क्रमलेखकोश गिट स्थिति। प्रयुक्त गिट स्थिति। आवृत गिट स्थिति। कच्चा गिट स्थिति। खुला + विश्वसनीय समूहसम्पाद्य लेखक + विश्वसनीय समूहसम्पाद्य पुनःप्रसारक विषयसूचक अनुचरित प्रमुखताएँ एचटीटीपी॰ प्रमाणीकरण निर्देशिका पुनःप्रसारक सूची + रुचियाँ वर्तमान बातचीत वर्तमानप्रवाह + श्रव्यदृश्याभिलेख अनुचरित + श्रव्यदृश्याभिलेख आरम्भ पोटली चिकित्सा अभिलेख मेलन शाला मेलन स्थल संगीतसूची संगीत अभिलेख मौन सूची + प्रकार %1$s मौनकृत नाम युक्त स्मर्त्तव्य सूची निलय सेवासंगणक निप॰ @@ -3792,6 +3991,7 @@ निर्गतपेटिका पुनःप्रसारक के लिए भुगतान व्यक्ति सूचियाँ + चित्र सूची चित्र टाँके पुटप्रसार कडी @@ -3818,6 +4018,7 @@ खोज पुनःप्रसारक लघु चलचित्र लघु चलचित्र (अनु) + समूह अनेकत्रावरोहण टिप्पणियाँ अनेकत्रावरोहण विश्वसनीय प्रदाता @@ -3826,12 +4027,15 @@ प्रयोक्ता स्थापना विकल्प उपयोगकर्ता स्थिति चलचित्र + चलचित्र सहयोग + चलचित्र सूची चलचित्र (अनु) + चलचित्र उपशीर्षक ध्वनि सन्देश ध्वनि उत्तर जगाना जाल स्मर्त्तव्यचिह्न - विकि॰ + समूहसम्पाद्य व्यायाम ज्साप उद्देश्य ज्साप मतदान @@ -3863,6 +4067,8 @@ मानचित्र पर स्थल परिवर्तन करें स्थानीय प्रणालियाँ मानचित्र पर एक स्थल चुनें + अमेथिस्ट का आण्ड्रोइड संस्करण एक bunker:// पता से हस्ताक्षर प्रवेश नहीं कर सकता। इनका वितरण करता है जिससे अन्य क्रमक अमेथिस्ट को हस्ताक्षर करने का अनुरोध कर सकते हैं। स्वयम हस्ताक्षर प्रवेश नहीं कर सकता। एक बाहरी हस्ताक्षरकर्ता क्रमक का उपयोग करें अथवा अमेथिस्ट का लेखनपीठ संगणक संस्करण। + वह एक क्रमक है जो आपके हस्ताक्षरकर्ता से संयोजन का अनुरोध कर रहा है। हस्ताक्षर प्रवेश करने की विधि नहीं है। पहले हस्ताक्षर प्रवेश करें। तत्पश्चात उसे निप॰छियालीस हस्ताक्षरण पटल से खोलें। आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को उद्धृत करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। अनुचरण करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। शब्द अथवा वाक्य छिपाने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें @@ -4217,6 +4423,7 @@ दूरस्थ हस्ताक्षरकर्ता क्रमक दूरस्थ हस्ताक्षरकर्ता + इस सूची में निजी सदस्य हैं जो केवल इसका कर्ता देख सकेगा %1$d सम्पत्ति समावेशित %1$d सम्पत्ति समावेशित @@ -4753,12 +4960,21 @@ दिनदर्शिकाएँ चतुरंग आविष्करण + कोष सम्पादन + खोज सम्पादन + कोष + खोज + कोष खोज + भूकोष घर अनुचरित आयात करें + एक खोज का अभिलेखन श्रव्यदृश्याभिलेख संदेश संगीतसूचियाँ संगीत + कोष को छिपाएँ + नई खोज सूचनाएँ कडियाँ पुटप्रसार @@ -4948,6 +5164,18 @@ अदृश्यीकरण अनुरोध भेजा गया अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं। एच॰२६५ अथवा एचईवीसी॰ संकुचनविस्तारणविधि उपयोग करें + सहयोग श्रेय स्वीकृत + सहयोग श्रेय अस्वीकृत + श्रेय स्वीकृत + ध्वनि इन से + सहभागी + प्रेरित इनसे + उद्धरणसूची + निजी सूची + + %1$d चलचित्र + %1$d चलचित्र + चलचित्र अवरोहण आरम्भ हुआ … चलचित्र चालक घुण्डियाँ चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया @@ -5007,4 +5235,13 @@ ग्राहक की लैटनिंग सेवा %1$s पर उपलब्ध नहीं। इसका परिकलन किया गया "%2$s" लैटनिंग पता से। अपक्रम : %3$s। जाँच करें यदि सेवा संगणक चल रहा है तथा यदि लैटनिंग पता सम्यक है %1$s से लैटनिंग चालान नहीं बना पाए। परिणाम जेसोन॰ में pr अम्श उपलब्ध नहीं। लैटनिंग चालान नहीं बना पाए। %1$s से संदेश : %2$s + यह यन्त्र अब आपके मार्मोट आमन्त्रणों को प्राप्त करता है। + कुंचिकापोटली प्रकाशन असफल। %1$s + किसी भी पुनःप्रसारक ने कुंचिकापोटली स्वीकारा नहीं। अपने पुनःप्रसारक स्थापना विकल्पों का जाँच करें तथा पुनःप्रयास करें। + भेजा जा रहा है\u2026 + भेजने में असफल। पुनःप्रयास करने के लिए दबाएँ। + + %1$d नया सन्देश + %1$d नए सन्देश + diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 303a3eaf4a..2b5065de18 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -166,6 +166,147 @@ Forgalmi dugó Útesemény + Geoláda + Hagyományos + Többpontos + Rejtélyes + Mikro + Kicsi + Normál + Nagy + Egyéb méret + Nehézség: %1$d + Terep: %1$d + Első megtaláló + Műalkotás + Kulcsküldetés + Ellenőrzött megtalálások + Igényelve + Archiválva + Eltévedt? Koppintson a tippek megjelenítéséhez + Nem olvasható? Koppintson egy másik olvasatért + Közeli + Megtaláltam! + Ellenőrzött megtalálás + A bizonyíték nem érvényes + Láda betöltése… + Elrejtette: %1$s + Navigálás + Koordináták másolása + Koordináták másolva + Fényképek + Kulcsküldetés küldetés + Naplók + Még senki sem naplózta ezt a ládát. Legyen Ön az első. + Megtaláltam + Nem találtam meg + Megjegyzés hozzáadása + Karbantartás szükségességének jelentése + Hozzáadás egy vadászathoz + Új vadászat… + Még nem hozott létre vadászatot. + Már szerepel ebben a vadászatban + %1$d láda van itt + Fénykép eltávolítása + Ezt a ládát archiválták. Már nem játszik. + Az első megtalálást %1$s szerezte meg + Az első megtalálás még szabad + Tulajdonos + Láda szerkesztése + Láda archiválása + Archiválás. A keresők látni fogják, hogy már nem játszik, de a lista olvasható marad. + Első megtaláló rögzítése + Közzétesz egy F címkét %1$s névvel. Ez végleges. + Még nincs ellenőrzött megtalálás, így nincs kit rögzíteni. + Ellenőrző kód megjelenítése + Nyomtassa ki és tegye a ládába. Az Amethyst nem tárolja — ha elveszíti, a keresők többé nem tudják bizonyítani, hogy ott jártak. + Nem találta meg + Megjegyzés + Karbantartást igényel + Megtalálás naplózása + Láda + Saját napló + Hogy ment? + Fénykép hozzáadása + Saját kulcsküldetés-válasz + Jelenlét bizonyítása + Olvassa be a ládában talált kódot + Másik kód beolvasása + Ellenőrizve — Ön járt a ládánál + Egyszer aláírva a dobozban lévő kóddal. A kulcs soha nem kerül mentésre, és soha nem hagyja el ezt a képernyőt. + Ez nem egy ellenőrző kód. + Ez a kód egy másik ládához tartozik. + Ez a láda nem kér bizonyítékot, így nyugodtan naplózhatja. + Az Ön saját helyzete nincs hozzácsatolva ehhez a naplóhoz. + Közzététel + Napló hozzáadása + Közeli + Térkép + Vadászatok + Megtalálások + Sajátjaim + Megtalálatlan + Nehézség ≤ %1$d + Terep ≤ %1$d + Van nyereménye + Bizonyíték szükséges + Saját helyzet használata + Rendezés távolság szerint + Legújabb elöl + Láda elrejtése + Itt még nincsenek ládák. Próbáljon szélesebb követési listát, vagy rejtse el az elsőt. + Még nem naplózott megtalálást. + Még nem rejtett el ládát. + Még nincsenek vadászatok. + Megtalálások + Első megtalálások + Ellenőrzött + Elrejtett + Középre + Egy láda elrejtéséhez nyomja meg hossza a térképet + Megnyitás + %1$d láda + Hol + Mi + Extrák + Bizonyíték + Átjátszók + Ez egy nyilvános helyzetet tesz közzé, körülbelül 5 méteres pontossággal. Bárki láthatja a Nostron, ez egy közzétett esemény nem vonható vissza.. + Válassza ki a helyet + Közzéteszi a(z) %1$s értéket, valamint durvább cellákat egészen 3 karakterig a közelségi kereséshez. + Név + Leírás + Típus + Méret + Nehézség + Terep + Tipp + Ezt látják majd a keresők + Tipp összekeverése (ROT13) + Kulcsküldetés küldetés + Módosítók + Jelenlét bizonyítékának megkövetelése + Ehhez a ládához elő lett állítva egy ellenőrzőkulcs. + Hol naplózzanak a keresők + Közzététel + Adjon nevet a ládának. + Válassza ki, hogy hol van elrejtve a láda. + %2$d / %1$d megtalálva + Vadászat indítása + %1$d láda + Cím + Leírás + Borítókép + Ládák sorrendben + Láda hozzáadása + Mozgatás fel + Mozgatás le + Eltávolítás + Oldal témája + Térkép stílusa + Adjon címet a vadászatnak. + Adjon hozzá legalább egy ládát. + Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra. Az erre küldött Zapek megoszlanak a következők között: %1$d%% Értéket az értékért @@ -214,6 +355,18 @@ Ez egy webhivatkozás. Ez egy egyszerű szöveg, nem Nostr-kód. Ez egy Nostr-kód, de ez a képernyő nem tudja megnyitni. + Profil + Megjegyzés + Nostr-kód + Átjátszó + Privát kulcs + Pénztárca-kapcsolat + Távoli aláíró címe + Aláírási kapcsolatkérés + Lightning számla + Cashu token + Webes hivatkozás + Egyszerű szöveg Hivatkozás megnyitása Másolás Másolva @@ -676,17 +829,31 @@ Óra Perc Másodperc + Használja a már rögzített edzéseit + Kapcsolja össze a Health Connectet (Samsung Health, Google Fit, Fitbit, Garmin…), hogy felépítse a Saját Fitneszt, a saját edzésösszegzését, és hogy ezt az edzést egy már rögzített órai edzésből töltse ki ahelyett, hogy minden számot kézzel írna be. Kapcsolódás %1$s km Health Connectből Mihez fér hozzá az Amethyst Health Connect és Amethyst + Az Amethyst a Health Connectet használja a Saját Fitness felépítéséhez — az Ön saját edzésösszegzéséhez, az eszközén. + A Saját fitnesz (menü → Ön → Saját fitnesz megmutatja, hogyan edzett: mennyit tett ezen a héten a múlt héthez képest, hogyan oszlik meg az ideje futás, kerékpározás, gyaloglás, úszás és edzőterem között, a legjobb erőfeszítéseit, hány napot edzett, és a jelenlegi sorozatát. Az Amethyst ezt a képet az órája vagy fitneszalkalmazása által a Health Connectbe már mentett edzésekből, valamint az Amethystben naplózott edzésekből építi fel. Az eszközén számítódik ki, és csak Önnek jelenik meg. Mihez fér hozzá az Amethyst, és miért + Edzés · milyen tevékenységet végzett, mikor kezdte és mennyi ideig tartott. Ez maga az edzés: ez hajtja az edzésszámot, az edzésidőt, a tevékenységenkénti bontást, az aktív napokat és a sorozatot. + Távolság · milyen messzire ment, megjelenítve az e heti és a múlt heti távolságát, a heti átlagát, a tevékenységenkénti távolságot és a leghosszabb távolságát. + Aktív és teljes kalória · az edzés által elégetett energia, megjelenítve az e heti energiáját és annak összehasonlítását a múlt héttel. Az aktív kalóriákat akkor használja, ha a forrás rögzíti azokat; a teljes kalória azoknak a forrásoknak a tartaléka, amelyek csak a teljes energiát rögzítik. + Pulzusszám · az átlag és a maximum minden edzésen, az időszakra vonatkozó átlagos és legmagasabb pulzusszámba kombinálva. Ez alapján különbözteti meg a könnyű hetet a kemény héttől azonos távolság mellett. + Lépések · egy futás, séta vagy túra lépésszáma, megjelenítve a heti lépésátlagát és a legmagasabb lépésszámát. + Emelkedés · mennyit mászott, megjelenítve a heti mászási átlagát és a legnagyobb mászását. Ez különbözteti meg a sík hetet a dombos héttől. Mit nem csinál az Amethyst + Csak az elmúlt 4 hét edzéseit olvassa, és csak akkor, amikor a „Saját fitnesz” vagy az edzésszerkesztő nyitva van. Soha nem olvas a háttérben. Soha nem ír semmit a Health Connectbe, és soha nem kéri le az edzési útvonalat, a tartózkodási helyet vagy bármilyen egyéb egészségügyi adatot. + Soha nem küldi el az egészségügyi adatait sehová. A „Saját fitnesz” a telefonján számítódik ki, és ott is marad. Ha később közzé szeretne tenni egy edzést, koppintson a „Megosztás” gombra azon az edzésen, olvassa el az Amethyst által kitöltött bejegyzést, és tegye közzé saját maga (egyszerre csak egy edzést, és soha nem automatikusan). + Az egész funkció nem kötelező. Bármikor visszavonhatja az engedélyeket a Health Connectben — a Saját fitnesz visszaáll a beállítására —, vagy kikapcsolhatja a szerkesztő javaslatait a Beállítások → Szerkesztési beállítások alatt. Az Amethyst többi része tovább működik. Olvassa el a teljes adatvédelmi nyilatkozatot Fitnesz Elmúlt 4 hét + Távolság, kalória és pulzusszám betöltése… Az edzés utolsó négy teljes hete: a közzétett edzések, valamint minden egyéb adat, amelyet a Health Connect rögzít ezen az eszközön. Az Amethyst az eszközön állítja össze az összefoglalót, és abból semmit sem tesz közzé. Ezen a héten elmúlt héten @@ -1161,6 +1328,8 @@ Szavazás lezárásának dátuma és ideje A szavazás lezárul %1$s múlva %1$s esemény keresése + Betöltés + Várakozás a privát üzenetek betöltésére… Zap küldése Nyilvános üzenet hozzáadása Privát üzenet hozzáadása @@ -1436,6 +1605,14 @@ Felhasználói beállítások Keresési beállítások Nincs találat a beállításokban erre: „%1$s” + Csoport frissítve + Melléklet + Üzenet + %1$s: %2$s + Csoport létrehozása + A Marmot meghívók egy másik eszközre mennek, és itt nem nyithatók meg. + Eltüntetés + Közzététel erről az eszközről Visszaállítja a Marmot-állapotot? Ez a művelet véglegesen töröl minden Marmot csoportos csevegést, üzenetelőzményt és MLS-kulcsot ezen az eszközön a jelenlegi fiókhoz. A partnerek nem kapnak erről értesítést, és a következő véglegesítésükig továbbra is láthatják Önt a csoportokban. Ez a művelet nem vonható vissza. Egy új kulcscsomag lesz közzétéve az alkalmazás következő szinkronizálásakor. Visszaállítás @@ -2025,6 +2202,8 @@ Videó lejátszása lebegő ablakban (rejtett, ha a rendszer nem támogatja) Közvetítés eszközre Videó küldése a Wi-Fi hálózaton található Chromecast eszközre (helyi fájloknál nem érhető el) + Feliratok + Feliratok be- vagy kikapcsolása (rejtve, ha a videónak nincs) Válassza ki a hangjegyzetek lejátszása közben megjelenő animációt. Ki Spektrumsávok @@ -3003,13 +3182,18 @@ Értesítés, amikor valaki válaszol az egyik bejegyzésre Válaszok Új válaszok + Nincs elküldve Válasz + Újra + Küldés… + Küldés… Értesítés ha valaki megosztja az Ön bejegyzését %1$s megosztotta az Ön bejegyzését Továbbosztások Új továbbosztások Értesítések, amikor egy ütemezett bejegyzés megjelenik vagy nem sikerül közzététenni. Ütemezett bejegyzések + Ezt a fiókot eltávolították az Amethystből. Értesítés, amikor valaki zapet küld Önnek %1$s satoshi neki: %1$s @@ -3210,6 +3394,10 @@ Némítás megszüntetése Nem található a visszahívási webcím a(z) %1$s válaszából Mégse + Ki + Feliratok kikapcsolása + Feliratok bekapcsolása + Feliratok Cashu token Pénzverde ellenőrzése… Nem sikerült a Cashut beváltani @@ -3695,10 +3883,12 @@ Kalandprológus Kalandos olvasmány Kalandjelenet + Alkalmazáslista Ajánlott alkalmazások Találkozó Alkalmazások Időpont-visszaigazolás + Cikklista Audio-fejléc Hangsáv Saját készítésű hangműsorok @@ -3711,6 +3901,7 @@ Blossom-hitelesítés Blossom kiszolgálók Könyvjelzőlista + Könyvjelzőkészlet Közvetítési átjátszók Naptár Csatorna alapadatai @@ -3748,6 +3939,7 @@ Emodzsicsomagok Eltűnő csevegés Eltűnő csevegőszobák + Kedvenc hírfolyamok Kedvenc hangműsorok Kedvenc átjátszók Fájl-fejléc @@ -3756,28 +3948,36 @@ Követési csomagok Területi követések Ajándékcsomagolások + Git-szerzők Git hibajegy Git javítófolt Beolvasztási kérés Beolvasztási kérés frissítése Git válasz Git tároló + Git-tárolók Git-állapot: Elfogadva Git-állapot: Lezárva Git-állapot: Piszkozat Git-állapot: Nyitott + Megbízható wiki-szerzők + Megbízható wiki-átjátszók Kulcsszókövetések Kiemelések Http-hitelesítés Indexelő átjátszók listája + Érdeklődési körök Élő csevegések Élő közvetítések + Médiakövetések + Média-kezdőcsomag Egészségügyi adatok Tárgyalószoba Megbeszélési helyszín Zenei lejátszási lista Zeneszám Lista némítása + Némítva a(z) %1$s típusra Címkézett könyvjelzők Hangszoba-kiszolgálók NIP @@ -3792,6 +3992,7 @@ Kimenő átjátszók fizetés kedvezményezettje Személylisták + Képlista Képek Rögzítettek Hangműsor-rész @@ -3818,6 +4019,7 @@ Keresési átjátszók Rövidek Rövidek (válasz) + Csoportok Torrent-megjegyzések torentek Megbízható szolgáltatók @@ -3826,7 +4028,10 @@ Felhasználói beállítások Felhasználói állapot Videó + Videó-együttműködés + Videólista Videó (válasz) + Videófeliratok Hangüzenet Hangos válasz Ébredés @@ -3863,6 +4068,8 @@ Helyszín módosítása a térképen Helyszíncsatornák Válasszon egy helyet a térképen + Az Amethyst Androidra nem tud bunker:// címmel bejelentkezni. Kiad ilyeneket, hogy más alkalmazások kérhessék tőle az aláírást, de ő maga nem tud ilyennel bejelentkezni. Használjon külső aláíró alkalmazást vagy az Amethyst Desktopot. + Ez egy alkalmazás, amely az aláírójához szeretne csatlakozni, nem bejelentkezési mód. Először jelentkezzen be, majd nyissa meg a NIP-46 aláíró képernyőről. Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni @@ -4217,6 +4424,7 @@ Távoli aláíró alkalmazás Távoli aláíró + Ennek a listának vannak privát tagjai, amelyeket csak a szerzője láthat %1$d összetevő egybecsomagolva %1$d asset egybecsomagolva @@ -4753,12 +4961,21 @@ Naptárak Sakk Felfedezés + Láda szerkesztése + Vadászat szerkesztése + Láda + Vadászat + Ládavadászatok + Geoládák Főoldal Követettek importálása + Megtalálás naplózása Média Üzenetek Lejátszási listák Zene + Láda elrejtése + Új vadászat Értesítések Részek Hangműsor @@ -4948,6 +5165,18 @@ Törlési kérés elküldve Jobb minőség kisebb fájlméret mellett, de nem minden eszköz támogatja a H.265 lejátszást. H.265/HEVC-kodek használata + Elfogadott egy együttműködési kreditet + Elutasított egy együttműködési kreditet + Kredit elfogadva + Hang innen: + Közreműködik + Ihlette: + Hivatkozások + Privát lista + + %1$d videó + %1$d videó + A videó letöltése megkezdődött… Videólejátszó gombjai Videó mentve a videógalériába @@ -5007,4 +5236,13 @@ A kedvezményezett lightning-szolgáltatása ennél: %1$s nem érhető el. A(z) „%2$s” lightning-cím lett kiszámítva. Hiba: %3$s. Ellenőrizze, hogy a kiszolgáló működik-e, és hogy a lightning-cím helyes-e Nem sikerült Lightning-számlát létrehozni a következőből: %1$s: A „PR” elem nem található a kapott JSON-ban. Nem lehetett lightning-számlát létrehozni. Üzenet a következőtől: %1$s: %2$s + Ez az eszköz mostantól fogadja a Marmot meghívóit. + Nem sikerült közzétenni a KeyPackage-et: %1$s + Egyetlen átjátszó sem fogadta el a KeyPackage-et. Ellenőrizze az átjátszó beállításait, és próbálja újra. + Küldés\u2026 + Nem sikerült elküldeni. Koppintson az újrapróbálkozáshoz. + + %1$d új üzenet + %1$d új üzenet + diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 48bcea77b6..1d201d6c6c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache @@ -1033,6 +1034,7 @@ class DesktopLocalCache : ICacheProvider { override fun justConsumeMyOwnEvent(event: Event): Boolean { if (!justVerify(event)) return false + LocallySignedEvents.mark(event) // For addressable/replaceable events, store in the addressable note cache // so state holders (Nip65RelayListState, etc.) pick it up via their flows if (event is AddressableEvent) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt new file mode 100644 index 0000000000..6bc2df62f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListDiff.kt @@ -0,0 +1,33 @@ +/* + * 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.quartz.concord.cord02Community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff + +/** Changes to the joined Concord communities. The list is encrypted, so it can only be compared as a whole. */ +@Immutable +class ConcordCommunityListDiff( + val communities: ContentChange, +) : EventDiff { + override fun removesData() = communities.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt index fff6f69bbb..9f001418c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.concord.cord02Community import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -47,7 +50,13 @@ class ConcordCommunityListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): ConcordCommunityListDiff? { + if (older !is ConcordCommunityListEvent || older.pubKey != pubKey) return null + return ConcordCommunityListDiff(ContentChange.between(older.content, content)) + } + override fun isContentEncoded() = true /** Decrypts this list's entries with [signer], or empty on failure / wrong key. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt new file mode 100644 index 0000000000..c3e149e94a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.experimental.ephemChat.list + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff + +/** Changes to the joined ephemeral chat rooms: public ones, and the private ones as a whole. */ +@Immutable +class EphemeralChatListDiff( + val rooms: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(rooms.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt index 02e98a628b..9ff4efd9e2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ephemChat/list/EphemeralChatListEvent.kt @@ -24,9 +24,12 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -43,7 +46,16 @@ class EphemeralChatListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): EphemeralChatListDiff? { + if (older !is EphemeralChatListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return EphemeralChatListDiff( + ListDiff.of(older.publicRooms(), publicRooms(), { it }), + privateItemsChangeFrom(older), + ) + } + fun publicRooms() = tags.rooms() fun publicRoomSet() = tags.roomSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt new file mode 100644 index 0000000000..29ddcc0f85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsDiff.kt @@ -0,0 +1,33 @@ +/* + * 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.quartz.experimental.nipA3 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff + +/** Changes to the NIP-A3 payment targets (the ways others can pay the user). */ +@Immutable +class PaymentTargetsDiff( + val targets: ListDiff, +) : EventDiff { + override fun removesData() = targets.hasRemovals() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt index 7ee3be366f..c1982a6d02 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt @@ -22,7 +22,10 @@ package com.vitorpamplona.quartz.experimental.nipA3 import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -33,7 +36,13 @@ class PaymentTargetsEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): PaymentTargetsDiff? { + if (older !is PaymentTargetsEvent || older.pubKey != pubKey) return null + return PaymentTargetsDiff(ListDiff.of(older.paymentTargets(), paymentTargets(), { it })) + } + fun paymentTargets(): List = tags.mapNotNull { PaymentTargetTag.parse(it) } companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt index 613cba6650..e9eb595b97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt @@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -48,7 +53,13 @@ class KeyPackageRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is KeyPackageRelayListEvent || older.pubKey != pubKey) return null + return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE) + } + /** * Relays from this list, with local-network entries dropped. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt new file mode 100644 index 0000000000..1d5b596544 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiff.kt @@ -0,0 +1,157 @@ +/* + * 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.quartz.nip01Core.diff + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * What changed between two versions of the same replaceable/addressable event. Each event + * kind defines its own diff class holding its own parsed objects (a follow list diffs + * `ContactTag`s, a nutzap info diffs `NutzapMintTag`s and its P2PK key…), built with the + * [ListDiff], [ValueChange] and [ContentChange] containers. + */ +interface EventDiff { + /** The newer version dropped something the older one had. */ + fun removesData(): Boolean +} + +/** + * An event that knows how to compare itself with an older version of itself. + * + * [diffFrom] returns null when [older] isn't a version of the same event: a different kind, + * author or, for addressables, d-tag. + */ +interface DiffableEvent { + fun diffFrom(older: Event): D? +} + +/** An item present in both versions whose details differ. */ +@Immutable +class ItemChange( + val before: T, + val after: T, +) + +/** + * The difference between two lists of parsed items. Items are matched by an identity key; + * a matched pair whose details differ is a change, not a removal plus an addition. + */ +@Immutable +class ListDiff( + val removed: List, + val added: List, + val changed: List>, +) { + fun isEmpty() = removed.isEmpty() && added.isEmpty() && changed.isEmpty() + + fun hasRemovals() = removed.isNotEmpty() + + companion object { + /** + * @param key the identity of an item inside the list. + * @param same whether two items with the same key carry the same details. + */ + fun of( + older: List, + newer: List, + key: (T) -> K, + same: (T, T) -> Boolean = { a, b -> a == b }, + ): ListDiff { + val olderByKey = LinkedHashMap(older.size) + older.forEach { olderByKey.getOrPut(key(it)) { it } } + val newerByKey = LinkedHashMap(newer.size) + newer.forEach { newerByKey.getOrPut(key(it)) { it } } + + val removed = mutableListOf() + val changed = mutableListOf>() + olderByKey.forEach { (k, before) -> + val after = newerByKey[k] + if (after == null) { + removed.add(before) + } else if (!same(before, after)) { + changed.add(ItemChange(before, after)) + } + } + val added = newerByKey.filterKeys { it !in olderByKey }.values.toList() + + return ListDiff(removed, added, changed) + } + } +} + +/** A single value that differs between two versions; null on a side means it wasn't set. */ +@Immutable +class ValueChange( + val before: T?, + val after: T?, +) { + fun isRemoval() = before != null && after == null + + companion object { + /** Null when both sides are the same. */ + fun of( + before: T?, + after: T?, + same: (T, T) -> Boolean = { a, b -> a == b }, + ): ValueChange? = + when { + before == null && after == null -> null + before != null && after != null && same(before, after) -> null + else -> ValueChange(before, after) + } + } +} + +/** + * How content that can't be read without decrypting (NIP-44 private list items, encrypted + * wallets and settings) changed as a whole. + */ +enum class ContentChange { + NONE, + ADDED, + CHANGED, + CLEARED, + ; + + fun isRemoval() = this == CLEARED + + /** + * Whether public items that disappeared alongside this change are really gone. When the + * private section only just appeared, they were most likely moved into it (made + * private), which loses nothing. A rewritten private section can't vouch for them: NIP-44 + * re-encrypts on every save, so it looks changed even when it holds the same items. + */ + fun publicRemovalsAreLoss(hasPublicRemovals: Boolean) = hasPublicRemovals && this != ADDED + + companion object { + fun between( + older: String, + newer: String, + ): ContentChange = + when { + older == newer -> NONE + older.isBlank() -> ADDED + newer.isBlank() -> CLEARED + else -> CHANGED + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt new file mode 100644 index 0000000000..e4660df8ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataDiff.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange +import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag + +/** + * Changes to a kind:0 profile, field by field as parsed by [UserMetadata]. A field that + * went blank counts as removed. JSON fields [UserMetadata] doesn't model are kept as raw + * `name → value` pairs in [otherFields], and NIP-39 identity claims are diffed as tags. + */ +@Immutable +class MetadataDiff( + val name: ValueChange?, + val displayName: ValueChange?, + val picture: ValueChange?, + val banner: ValueChange?, + val website: ValueChange?, + val about: ValueChange?, + val pronouns: ValueChange?, + val nip05: ValueChange?, + val lud06: ValueChange?, + val lud16: ValueChange?, + val clinkOffer: ValueChange?, + val bot: ValueChange?, + val birthday: ValueChange?, + val otherFields: ListDiff>, + val identityClaims: ListDiff, +) : EventDiff { + private fun fieldChanges(): List> = listOfNotNull(name, displayName, picture, banner, website, about, pronouns, nip05, lud06, lud16, clinkOffer, bot, birthday) + + // Dropping the deprecated NIP-24 aliases (displayName, username) is a cleanup, not a loss: + // clients that follow NIP-24 remove them and keep display_name / name. + override fun removesData() = + fieldChanges().any { it.isRemoval() } || + otherFields.removed.any { it.first !in DEPRECATED_ALIASES } || + identityClaims.hasRemovals() + + companion object { + /** JSON keys [UserMetadata] parses into typed fields above. */ + val DEPRECATED_ALIASES = setOf("displayName", "username") + + val MODELED_FIELDS = setOf("name", "display_name", "picture", "banner", "website", "about", "pronouns", "nip05", "lud06", "lud16", "clink_offer", "bot", "birthday") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 1bf9afe078..1729917c97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -22,10 +22,14 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag @@ -66,7 +70,55 @@ class MetadataEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent, SearchableEvent { + override fun diffFrom(older: Event): MetadataDiff? { + if (older !is MetadataEvent || older.pubKey != pubKey) return null + val before = older.contactMetaData() ?: UserMetadata() + val after = contactMetaData() ?: UserMetadata() + + fun text( + b: String?, + a: String?, + ) = ValueChange.of(b?.ifBlank { null }, a?.ifBlank { null }) + + return MetadataDiff( + name = text(before.name, after.name), + displayName = text(before.displayName, after.displayName), + picture = text(before.picture, after.picture), + banner = text(before.banner, after.banner), + website = text(before.website, after.website), + about = text(before.about, after.about), + pronouns = text(before.pronouns, after.pronouns), + nip05 = text(before.nip05, after.nip05), + lud06 = text(before.lud06, after.lud06), + lud16 = text(before.lud16, after.lud16), + clinkOffer = text(before.clinkOffer, after.clinkOffer), + // "bot": false is the default: dropping it loses nothing, so only `true` counts. + bot = ValueChange.of(before.bot?.takeIf { it }, after.bot?.takeIf { it }), + birthday = + ValueChange.of(before.birthday, after.birthday) { b, a -> + b.year == a.year && b.month == a.month && b.day == a.day + }, + otherFields = ListDiff.of(older.unmodeledFields(), unmodeledFields(), { it.first }), + identityClaims = + ListDiff.of( + older.tags.mapNotNull(IdentityClaimTag::parse), + tags.mapNotNull(IdentityClaimTag::parse), + { it.platformIdentity() }, + { b, a -> b.proof == a.proof }, + ), + ) + } + + /** Filled JSON fields [UserMetadata] has no typed property for, as raw pairs. */ + private fun unmodeledFields(): List> = + contactMetadataJson()?.mapNotNull { (key, value) -> + if (key in MetadataDiff.MODELED_FIELDS) return@mapNotNull null + val text = if (value is JsonPrimitive) value.content else value.toString() + if (text.isBlank() || text == "null") null else key to text + } ?: emptyList() + override fun isContentEncoded() = true // Profile content is JSON, so we parse it and index only the diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt new file mode 100644 index 0000000000..248e6551da --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListDiff.kt @@ -0,0 +1,34 @@ +/* + * 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.quartz.nip02FollowList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag + +/** Changes to the follow list: people followed and unfollowed, and new relay hints or petnames. The deprecated relay map in the content is ignored. */ +@Immutable +class ContactListDiff( + val follows: ListDiff, +) : EventDiff { + override fun removesData() = follows.hasRemovals() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index 293fc606d1..ceb0d9c66b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip02FollowList import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -44,7 +47,15 @@ class ContactListEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent, PubKeyHintProvider { + override fun diffFrom(older: Event): ContactListDiff? { + if (older !is ContactListEvent || older.pubKey != pubKey) return null + return ContactListDiff( + ListDiff.of(older.follows(), follows(), { it.pubKey }, { a, b -> a.relayUri == b.relayUri && a.petname == b.petname }), + ) + } + override fun pubKeyHints() = tags.mapNotNull(ContactTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(ContactTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt index 10847c6d38..da8184c850 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt @@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.nip17Dm.settings import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -39,7 +44,13 @@ class ChatMessageRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is ChatMessageRelayListEvent || older.pubKey != pubKey) return null + return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE) + } + fun relays(): List = tags.mapNotNull(RelayTag::parse) /** Every relay in this list, local ones included. For reading back our OWN list. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt new file mode 100644 index 0000000000..69ab74f29c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.nip28PublicChat.list + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag + +/** Changes to the joined public chats: public channels as parsed tags, private ones as a whole. */ +@Immutable +class ChannelListDiff( + val channels: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(channels.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt index 5f97ccde41..27f84bb63c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip28PublicChat.list import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -47,7 +50,16 @@ class ChannelListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent, EventHintProvider { + override fun diffFrom(older: Event): ChannelListDiff? { + if (older !is ChannelListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return ChannelListDiff( + ListDiff.of(older.tags.channels(), tags.channels(), { it.eventId }, { a, b -> a.relay == b.relay }), + privateItemsChangeFrom(older), + ) + } + override fun eventHints() = tags.mapNotNull(ChannelTag::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(ChannelTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt index 30a0f6e6ee..9734cb1d4f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip37Drafts/privateOutbox/PrivateOutboxRelayListEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip37Drafts.privateOutbox import androidx.compose.runtime.Immutable 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.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.privateRelays import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays @@ -48,7 +52,13 @@ class PrivateOutboxRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is PrivateOutboxRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) + } + fun publicRelays() = tags.mapNotNull(RelayTag::parse) suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.mapNotNull(RelayTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt index fe90d885d0..f9dc36de5a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchRelayListEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip50Search import androidx.compose.runtime.Immutable 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.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.searchRelays @@ -48,7 +52,13 @@ class SearchRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is SearchRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) + } + fun publicRelays() = tags.relays() suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index 2f901168e4..a47a2d4a74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent @@ -41,6 +42,12 @@ abstract class PrivateTagArrayEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun isContentEncoded() = true + /** + * How the NIP-44 encrypted private items changed since [older]. They can't be compared + * item by item without decrypting, so only as a whole. + */ + fun privateItemsChangeFrom(older: PrivateTagArrayEvent) = ContentChange.between(older.content, content) + suspend fun decrypt(signer: NostrSigner): TagArray { if (signer.pubKey != pubKey) throw SignerExceptions.UnauthorizedDecryptionException() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt new file mode 100644 index 0000000000..d46cf70ff7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.nip51Lists.favoriteAlgoFeedsList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +/** Changes to the favorite algorithmic feeds: public ones as parsed bookmarks, private ones as a whole. */ +@Immutable +class FavoriteAlgoFeedsListDiff( + val feeds: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(feeds.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt index e7a00838fe..a7a8ec1d19 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -42,7 +45,16 @@ class FavoriteAlgoFeedsListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): FavoriteAlgoFeedsListDiff? { + if (older !is FavoriteAlgoFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return FavoriteAlgoFeedsListDiff( + ListDiff.of(older.publicFavoriteAlgoFeeds(), publicFavoriteAlgoFeeds(), { it.address }, { a, b -> a.relayHint == b.relayHint }), + privateItemsChangeFrom(older), + ) + } + fun publicFavoriteAlgoFeeds(): List = tags.mapNotNull(AddressBookmark::parse) suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt new file mode 100644 index 0000000000..4e0c5ec47e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListDiff.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nip51Lists.geohashList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff + +/** Changes to the followed locations (geohashes): public ones, and the private ones as a whole. */ +@Immutable +class GeohashListDiff( + val geohashes: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(geohashes.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt index 4822e39119..936a691d84 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.geohashList import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -45,7 +48,16 @@ class GeohashListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): GeohashListDiff? { + if (older !is GeohashListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return GeohashListDiff( + ListDiff.of(older.publicGeohashes(), publicGeohashes(), { it.lowercase() }), + privateItemsChangeFrom(older), + ) + } + fun publicGeohashes() = tags.geohashList() suspend fun decryptPrivateGeohashes(signer: NostrSigner) = privateTags(signer)?.geohashList() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt new file mode 100644 index 0000000000..21faacdee8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nip51Lists.hashtagList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff + +/** Changes to the followed hashtags: public ones, and the private ones as a whole. */ +@Immutable +class HashtagListDiff( + val hashtags: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(hashtags.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt index 8d862ce132..67af6cddc7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.hashtagList import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -45,7 +48,16 @@ class HashtagListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): HashtagListDiff? { + if (older !is HashtagListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return HashtagListDiff( + ListDiff.of(older.publicHashtags(), publicHashtags(), { it.lowercase() }), + privateItemsChangeFrom(older), + ) + } + fun publicHashtags() = tags.mapNotNull(HashtagTag::parse) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt new file mode 100644 index 0000000000..079e89f180 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.nip51Lists.muteList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag + +/** Changes to the mute list: public mutes (people, words, hashtags, threads) as parsed tags, and the encrypted private mutes as a whole. */ +@Immutable +class MuteListDiff( + val publicMutes: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(publicMutes.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt index 1230fe16b4..500ab86628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/MuteListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.muteList import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -46,7 +49,16 @@ class MuteListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent, PubKeyHintProvider { + override fun diffFrom(older: Event): MuteListDiff? { + if (older !is MuteListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return MuteListDiff( + ListDiff.of(older.publicMutes(), publicMutes(), { it.toTagIdOnly().toList() }, { a, b -> a.toTagArray().contentEquals(b.toTagArray()) }), + privateItemsChangeFrom(older), + ) + } + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt index 00ed9542e4..621d164b38 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BlockedRelayListEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists import androidx.compose.runtime.Immutable 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.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -48,7 +51,13 @@ class BlockedRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is BlockedRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) + } + fun publicRelays() = tags.relays() suspend fun privateRelays(signer: NostrSigner) = privateTags(signer)?.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt index 56dd3a3a4b..ad137a9fd9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/IndexerRelayListEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists import androidx.compose.runtime.Immutable 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.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -48,7 +51,13 @@ class IndexerRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is IndexerRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) + } + fun publicRelays() = tags.relays() suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt index 598361a923..a758db06d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists import androidx.compose.runtime.Immutable 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.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -48,7 +51,13 @@ class RelayFeedsListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is RelayFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) + } + fun publicRelays() = tags.relays() suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt new file mode 100644 index 0000000000..a4b04e7192 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** Changes to a list of relays (DM inbox, search, indexer, blocked, trusted, private outbox…): the public relays, and the encrypted private ones as a whole. */ +@Immutable +class RelayListDiff( + val relays: ListDiff, + val privateRelays: ContentChange, +) : EventDiff { + override fun removesData() = privateRelays.publicRemovalsAreLoss(relays.hasRemovals()) || privateRelays.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt index 1fd5c7fd58..98edcc200b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/TrustedRelayListEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip51Lists.relayLists import androidx.compose.runtime.Immutable 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.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -48,7 +51,13 @@ class TrustedRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): RelayListDiff? { + if (older !is TrustedRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) + } + fun publicRelays() = tags.relays() suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt new file mode 100644 index 0000000000..a141de829a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListDiff.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nip51Lists.simpleGroupList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff + +/** Changes to the joined NIP-29 groups: public ones as parsed group tags (renames included), private ones as a whole. */ +@Immutable +class SimpleGroupListDiff( + val groups: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(groups.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt index bc7620a372..8fb1843951 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/simpleGroupList/SimpleGroupListEvent.kt @@ -22,8 +22,12 @@ package com.vitorpamplona.quartz.nip51Lists.simpleGroupList import androidx.compose.runtime.Immutable 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.core.TagArray +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent @@ -39,7 +43,16 @@ class SimpleGroupListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): SimpleGroupListDiff? { + if (older !is SimpleGroupListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return SimpleGroupListDiff( + ListDiff.of(older.publicGroups(), publicGroups(), { it.groupId + "@" + (RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.url ?: it.relayUrl) }, { a, b -> a.name == b.name }), + privateItemsChangeFrom(older), + ) + } + fun publicGroups() = tags.mapNotNull(GroupTag::parse) suspend fun privateGroups(signer: NostrSigner) = privateTags(signer)?.mapNotNull(GroupTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt new file mode 100644 index 0000000000..977ec8d256 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletDiff.kt @@ -0,0 +1,33 @@ +/* + * 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.quartz.nip60Cashu.wallet + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff + +/** Changes to the NIP-60 wallet. Its mints and keys are encrypted, so it can only be compared as a whole. */ +@Immutable +class CashuWalletDiff( + val wallet: ContentChange, +) : EventDiff { + override fun removesData() = wallet.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt index 9b2fd15b27..538df0a7f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt @@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip60Cashu.wallet import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -47,7 +50,13 @@ class CashuWalletEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): CashuWalletDiff? { + if (older !is CashuWalletEvent || older.pubKey != pubKey) return null + return CashuWalletDiff(ContentChange.between(older.content, content)) + } + /** * Decrypts the content to get the wallet's private tags (mints and privkey). */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt new file mode 100644 index 0000000000..ad8ab36916 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoDiff.kt @@ -0,0 +1,44 @@ +/* + * 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.quartz.nip61Nutzaps.info + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag + +/** Changes to the NIP-61 nutzap receiving settings: accepted mints (and their units), relays, and the P2PK key nutzaps are locked to. */ +@Immutable +class NutzapInfoDiff( + val mints: ListDiff, + val relays: ListDiff, + val p2pkPubkey: ValueChange?, +) : EventDiff { + // A mint that stops accepting a unit (sat, usd…) loses that unit's nutzaps. + override fun removesData() = + mints.hasRemovals() || + mints.changed.any { !it.after.units.containsAll(it.before.units) } || + relays.hasRemovals() || + p2pkPubkey?.isRemoval() == true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt index 8a876ccc7d..20fb2f3be8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/info/NutzapInfoEvent.kt @@ -23,8 +23,12 @@ package com.vitorpamplona.quartz.nip61Nutzaps.info import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.diff.ValueChange import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag @@ -38,7 +42,17 @@ class NutzapInfoEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): NutzapInfoDiff? { + if (older !is NutzapInfoEvent || older.pubKey != pubKey) return null + return NutzapInfoDiff( + ListDiff.of(older.mints(), mints(), { it.mintUrl.trimEnd('/').lowercase() }, { a, b -> a.units.toSet() == b.units.toSet() }), + ListDiff.of(older.relays(), relays(), { it }), + ValueChange.of(older.p2pkPubkey(), p2pkPubkey()), + ) + } + fun mints() = tags.mints() fun relays() = tags.relays() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt new file mode 100644 index 0000000000..3af1f02cb2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListDiff.kt @@ -0,0 +1,38 @@ +/* + * 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.quartz.nip65RelayList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo + +/** Changes to the NIP-65 relay list: relays added, removed, or switched between read, write and both. */ +@Immutable +class AdvertisedRelayListDiff( + val relays: ListDiff, +) : EventDiff { + // A relay that stops being read (inbox) or write (outbox) loses that role even though it + // is still listed: going from read+write to write-only drops the user's inbox there. + override fun removesData() = + relays.hasRemovals() || + relays.changed.any { (it.before.type.isRead() && !it.after.type.isRead()) || (it.before.type.isWrite() && !it.after.type.isWrite()) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt index 8a619a021f..e9689b3fc0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt @@ -23,11 +23,16 @@ package com.vitorpamplona.quartz.nip65RelayList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -38,7 +43,24 @@ class AdvertisedRelayListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): AdvertisedRelayListDiff? { + if (older !is AdvertisedRelayListEvent || older.pubKey != pubKey) return null + return AdvertisedRelayListDiff(ListDiff.of(older.relaysByUrl(), relaysByUrl(), { it.relayUrl }, { a, b -> a.type == b.type })) + } + + /** One entry per relay: a separate read tag and write tag for the same URL mean both. */ + private fun relaysByUrl(): List { + val byUrl = LinkedHashMap() + tags.forEach { tag -> + val info = AdvertisedRelayInfo.parse(tag) ?: return@forEach + val current = byUrl[info.relayUrl] + byUrl[info.relayUrl] = if (current == null || current == info.type) info.type else AdvertisedRelayType.BOTH + } + return byUrl.map { (url, type) -> AdvertisedRelayInfo(url, type) } + } + fun relays() = tags.mapNotNull(AdvertisedRelayInfo::parse) fun relaysNorm() = tags.mapNotNull(AdvertisedRelayInfo::parseNorm) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt new file mode 100644 index 0000000000..5f7fb9b95d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.nip72ModCommunities.follow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag + +/** Changes to the joined communities: public ones as parsed tags, private ones as a whole. */ +@Immutable +class CommunityListDiff( + val communities: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(communities.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt index 7a1536713a..0a25bf51d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/follow/CommunityListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip72ModCommunities.follow import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -45,7 +48,16 @@ class CommunityListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent, AddressHintProvider { + override fun diffFrom(older: Event): CommunityListDiff? { + if (older !is CommunityListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return CommunityListDiff( + ListDiff.of(older.publicCommunities(), publicCommunities(), { it.address }, { a, b -> a.relayHint == b.relayHint }), + privateItemsChangeFrom(older), + ) + } + override fun addressHints() = tags.mapNotNull(CommunityTag::parseAsHint) override fun linkedAddressIds() = tags.mapNotNull(CommunityTag::parseAddressId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt new file mode 100644 index 0000000000..0a810f407e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataDiff.kt @@ -0,0 +1,33 @@ +/* + * 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.quartz.nip78AppData + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff + +/** Changes to an app's NIP-78 data. It is usually encrypted, so it is compared as a whole. */ +@Immutable +class AppSpecificDataDiff( + val data: ContentChange, +) : EventDiff { + override fun removesData() = data.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index eba9fd265e..7d7b06d258 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip78AppData import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag @@ -36,7 +39,13 @@ class AppSpecificDataEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): AppSpecificDataDiff? { + if (older !is AppSpecificDataEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return AppSpecificDataDiff(ContentChange.between(older.content, content)) + } + override fun isContentEncoded() = true companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt new file mode 100644 index 0000000000..3eb66fa456 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListDiff.kt @@ -0,0 +1,36 @@ +/* + * 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.quartz.nip85TrustedAssertions.list + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag + +/** Changes to the NIP-85 trusted service providers: who computes each service, and from which relay. */ +@Immutable +class TrustProviderListDiff( + val providers: ListDiff, + val privateItems: ContentChange, +) : EventDiff { + override fun removesData() = privateItems.publicRemovalsAreLoss(providers.hasRemovals()) || privateItems.isRemoval() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt index c901813730..ff07ed550c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/list/TrustProviderListEvent.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.quartz.nip85TrustedAssertions.list import androidx.compose.runtime.Immutable 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.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -42,7 +45,16 @@ class TrustProviderListEvent( tags: Array>, content: String, sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): TrustProviderListDiff? { + if (older !is TrustProviderListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return TrustProviderListDiff( + ListDiff.of(older.serviceProviders(), serviceProviders(), { it.service to it.pubkey }, { a, b -> a.relayUrl == b.relayUrl }), + privateItemsChangeFrom(older), + ) + } + fun serviceProviders() = tags.serviceProviders() companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt new file mode 100644 index 0000000000..523dbcf127 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListDiff.kt @@ -0,0 +1,33 @@ +/* + * 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.quartz.nipB1Bolt12Zaps.offer + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff + +/** Changes to the published BOLT12 offers. */ +@Immutable +class Bolt12OfferListDiff( + val offers: ListDiff, +) : EventDiff { + override fun removesData() = offers.hasRemovals() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt index e1c4c17191..1457dcd55d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB1Bolt12Zaps/offer/Bolt12OfferListEvent.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -50,7 +53,13 @@ class Bolt12OfferListEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): Bolt12OfferListDiff? { + if (older !is Bolt12OfferListEvent || older.pubKey != pubKey) return null + return Bolt12OfferListDiff(ListDiff.of(older.offers(), offers(), { it })) + } + /** All canonical raw BOLT12 offers (`lno1...`) this user publishes, in tag order. */ fun offers(): List = tags.mapNotNull(OfferTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt new file mode 100644 index 0000000000..6f2e981e8f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/diff/EventDiffTest.kt @@ -0,0 +1,363 @@ +/* + * 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.quartz.nip01Core.diff + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EventDiffTest { + private val signer = NostrSignerSync(KeyPair()) + + private inline fun sign( + kind: Int, + createdAt: Long, + tags: Array>, + content: String = "", + ): T = assertIs(signer.sign(createdAt, kind, tags, content)) + + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + @Test + fun followListDiffsContactTags() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("p", bob, "wss://a.com/", "bobby"))) + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", bob, "wss://b.com/"), arrayOf("p", carol)), "") + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf(alice), diff.follows.removed.map { it.pubKey }) + assertEquals(listOf(carol), diff.follows.added.map { it.pubKey }) + val change = diff.follows.changed.single() + assertEquals("bobby", change.before.petname) + assertEquals("wss://b.com/", change.after.relayUri?.url) + assertTrue(diff.removesData()) + } + + @Test + fun followListOnlyAddingDoesNotRemove() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "{\"wss://a.com\":{}}") + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice), arrayOf("p", bob)), "") + val diff = assertNotNull(newer.diffFrom(older)) + assertFalse(diff.removesData()) + } + + @Test + fun muteListDiffsMuteTagsAndPrivateItems() { + val older = + sign( + MuteListEvent.KIND, + 100, + arrayOf(arrayOf("p", alice), arrayOf("t", "spam"), arrayOf("word", "crypto"), arrayOf("e", bob)), + "encrypted-private-items", + ) + val newer = sign(MuteListEvent.KIND, 200, arrayOf(arrayOf("word", "crypto")), "") + val diff = assertNotNull(newer.diffFrom(older)) + val removed = diff.publicMutes.removed + assertEquals(3, removed.size) + assertEquals(alice, assertIs(removed[0]).pubKey) + assertEquals("spam", assertIs(removed[1]).hashtag) + assertEquals(bob, assertIs(removed[2]).eventId) + assertTrue(diff.publicMutes.added.none { it is WordTag }) + assertEquals(ContentChange.CLEARED, diff.privateItems) + } + + @Test + fun rewrittenPrivateItemsAreChangedNotRemoved() { + val older = sign(MuteListEvent.KIND, 100, arrayOf(), "cipher-a") + val newer = sign(MuteListEvent.KIND, 200, arrayOf(), "cipher-b") + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(ContentChange.CHANGED, diff.privateItems) + assertFalse(diff.removesData()) + } + + @Test + fun makingPublicItemsPrivateIsNotALoss() { + val older = sign(MuteListEvent.KIND, 100, arrayOf(arrayOf("p", alice), arrayOf("word", "crypto")), "") + val newer = sign(MuteListEvent.KIND, 200, arrayOf(), "encrypted-now-holding-both") + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(2, diff.publicMutes.removed.size) + assertEquals(ContentChange.ADDED, diff.privateItems) + assertFalse(diff.removesData()) + } + + @Test + fun droppingPublicItemsWhileRewritingPrivateOnesIsALoss() { + val older = sign(MuteListEvent.KIND, 100, arrayOf(arrayOf("p", alice)), "cipher-a") + val newer = sign(MuteListEvent.KIND, 200, arrayOf(), "cipher-b") + assertTrue(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun nip65DiffsRelayTypes() { + val older = + sign( + AdvertisedRelayListEvent.KIND, + 100, + arrayOf(arrayOf("r", "wss://a.com/"), arrayOf("r", "wss://b.com/", "read"), arrayOf("r", "wss://c.com/")), + ) + val newer = + sign( + AdvertisedRelayListEvent.KIND, + 200, + arrayOf(arrayOf("r", "wss://a.com/", "write"), arrayOf("r", "wss://b.com/", "read"), arrayOf("client", "x")), + ) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf("wss://c.com/"), diff.relays.removed.map { it.relayUrl.url }) + val change = diff.relays.changed.single() + assertEquals(AdvertisedRelayType.BOTH, change.before.type) + assertEquals(AdvertisedRelayType.WRITE, change.after.type) + } + + @Test + fun relayListsDiffNormalizedUrls() { + val older = sign(BlockedRelayListEvent.KIND, 100, arrayOf(arrayOf("relay", "wss://spam.com/"))) + val newer = sign(BlockedRelayListEvent.KIND, 200, arrayOf()) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf(NormalizedRelayUrl("wss://spam.com/")), diff.relays.removed) + } + + @Test + fun profileDiffsTypedFields() { + val older = sign(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","about":"hi","lud16":"v@x.com","banner":"","twitter":"vp"}""") + val newer = sign(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor2","about":"hi","banner":"https://b"}""") + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals("vitor", diff.name?.before) + assertEquals("vitor2", diff.name?.after) + assertNull(diff.about) + assertTrue(assertNotNull(diff.lud16).isRemoval()) + assertEquals("https://b", diff.banner?.after) + assertNull(diff.banner?.before) + assertEquals(listOf("twitter" to "vp"), diff.otherFields.removed) + assertTrue(diff.removesData()) + } + + @Test + fun droppingTheDefaultBotFlagIsNotALoss() { + val older = sign(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","bot":false}""") + val newer = sign(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor"}""") + val diff = assertNotNull(newer.diffFrom(older)) + assertNull(diff.bot) + assertFalse(diff.removesData()) + } + + @Test + fun groupsDiffGroupTagsIncludingRenames() { + val older = sign(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("group", "abc", "wss://g.com", "Friends"), arrayOf("group", "xyz", "wss://g.com"))) + val newer = sign(SimpleGroupListEvent.KIND, 200, arrayOf(arrayOf("group", "abc", "wss://g.com", "Family"))) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals(listOf("xyz"), diff.groups.removed.map { it.groupId }) + assertEquals( + "Family", + diff.groups.changed + .single() + .after.name, + ) + } + + @Test + fun trustProvidersDiffServiceProviderTags() { + val older = sign(TrustProviderListEvent.KIND, 100, arrayOf(arrayOf("30382:rank", alice, "wss://a.com"))) + val newer = sign(TrustProviderListEvent.KIND, 200, arrayOf()) + val removed = assertNotNull(newer.diffFrom(older)).providers.removed.single() + assertEquals("rank", removed.service.type) + assertEquals(alice, removed.pubkey) + } + + @Test + fun nutzapInfoDiffsMintsRelaysAndKey() { + val older = sign(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice))) + val newer = sign(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd"), arrayOf("pubkey", bob))) + val diff = assertNotNull(newer.diffFrom(older)) + assertEquals( + listOf("sat", "usd"), + diff.mints.changed + .single() + .after.units, + ) + assertEquals(alice, diff.p2pkPubkey?.before) + assertEquals(bob, diff.p2pkPubkey?.after) + assertFalse(diff.removesData()) + } + + @Test + fun otherEventsAreNotComparable() { + val mutes = sign(MuteListEvent.KIND, 100, arrayOf()) + val follows = sign(ContactListEvent.KIND, 200, arrayOf()) + val someoneElse = assertIs(NostrSignerSync(KeyPair()).sign(300, ContactListEvent.KIND, arrayOf(), "")) + assertNull(follows.diffFrom(mutes)) + assertNull(someoneElse.diffFrom(follows)) + } + + // Bookkeeping tags other clients rewrite on every save: never user data. + private val olderBookkeeping = arrayOf(arrayOf("client", "Amethyst"), arrayOf("nonce", "123", "20"), arrayOf("expiration", "1000")) + private val newerBookkeeping = arrayOf(arrayOf("client", "OtherApp", "31990:abc:def"), arrayOf("nonce", "999", "16"), arrayOf("expiration", "5000")) + + @Test + fun clientPowAndExpirationTagsAreIgnored() { + val follows = arrayOf(arrayOf("p", alice)) + val contacts = + sign(ContactListEvent.KIND, 200, follows + newerBookkeeping) + .diffFrom(sign(ContactListEvent.KIND, 100, follows + olderBookkeeping)) + assertTrue(assertNotNull(contacts).follows.isEmpty()) + + val mutes = arrayOf(arrayOf("word", "spam")) + val muteDiff = + assertNotNull( + sign(MuteListEvent.KIND, 200, mutes + newerBookkeeping) + .diffFrom(sign(MuteListEvent.KIND, 100, mutes + olderBookkeeping)), + ) + assertTrue(muteDiff.publicMutes.isEmpty()) + assertEquals(ContentChange.NONE, muteDiff.privateItems) + + val relays = arrayOf(arrayOf("r", "wss://a.com/")) + val nip65 = + sign(AdvertisedRelayListEvent.KIND, 200, relays + newerBookkeeping) + .diffFrom(sign(AdvertisedRelayListEvent.KIND, 100, relays + olderBookkeeping)) + assertTrue(assertNotNull(nip65).relays.isEmpty()) + + val blocked = arrayOf(arrayOf("relay", "wss://spam.com/")) + val blockedDiff = + sign(BlockedRelayListEvent.KIND, 200, blocked + newerBookkeeping) + .diffFrom(sign(BlockedRelayListEvent.KIND, 100, blocked + olderBookkeeping)) + assertTrue(assertNotNull(blockedDiff).relays.isEmpty()) + + val mints = arrayOf(arrayOf("mint", "https://mint.com", "sat"), arrayOf("pubkey", alice)) + val nutzap = + assertNotNull( + sign(NutzapInfoEvent.KIND, 200, mints + newerBookkeeping) + .diffFrom(sign(NutzapInfoEvent.KIND, 100, mints + olderBookkeeping)), + ) + assertTrue(nutzap.mints.isEmpty() && nutzap.relays.isEmpty()) + assertNull(nutzap.p2pkPubkey) + + val profile = """{"name":"vitor"}""" + val metadata = + assertNotNull( + sign(MetadataEvent.KIND, 200, newerBookkeeping, profile) + .diffFrom(sign(MetadataEvent.KIND, 100, olderBookkeeping, profile)), + ) + assertFalse(metadata.removesData()) + assertTrue(metadata.otherFields.isEmpty() && metadata.identityClaims.isEmpty()) + assertNull(metadata.name) + } + + @Test + fun droppingOnlyBookkeepingTagsIsNotALoss() { + val older = sign(ContactListEvent.KIND, 100, arrayOf(arrayOf("p", alice)) + olderBookkeeping) + val newer = sign(ContactListEvent.KIND, 200, arrayOf(arrayOf("p", alice))) + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun listDiffMatchesByKey() { + val diff = ListDiff.of(listOf(1 to "a", 2 to "b"), listOf(2 to "B", 3 to "c"), { it.first }) + assertEquals(listOf(1 to "a"), diff.removed) + assertEquals(listOf(3 to "c"), diff.added) + assertEquals(2 to "B", diff.changed.single().after) + } + + private fun nip65( + createdAt: Long, + vararg tags: Array, + ) = sign(AdvertisedRelayListEvent.KIND, createdAt, arrayOf(*tags)) + + @Test + fun nip65RelayThatStopsBeingAnInboxIsALoss() { + val older = nip65(100, arrayOf("r", "wss://a.com/")) + val newer = nip65(200, arrayOf("r", "wss://a.com/", "write")) + assertTrue(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun nip65RelayThatGainsARoleIsNotALoss() { + val older = nip65(100, arrayOf("r", "wss://a.com/", "write")) + val newer = nip65(200, arrayOf("r", "wss://a.com/")) + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun nip65SplitReadAndWriteTagsMeanBoth() { + val older = nip65(100, arrayOf("r", "wss://a.com/", "read"), arrayOf("r", "wss://a.com/", "write")) + val newer = nip65(200, arrayOf("r", "wss://a.com/")) + val diff = assertNotNull(newer.diffFrom(older)) + assertTrue(diff.relays.isEmpty()) + assertFalse(diff.removesData()) + } + + @Test + fun nutzapMintThatDropsAUnitIsALoss() { + val older = sign(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://mint.com", "sat", "usd"))) + val newer = sign(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com", "sat"))) + assertTrue(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun nutzapMintUrlSpellingAndUnitOrderAreNotChanges() { + val older = sign(NutzapInfoEvent.KIND, 100, arrayOf(arrayOf("mint", "https://Mint.com", "sat", "usd"))) + val newer = sign(NutzapInfoEvent.KIND, 200, arrayOf(arrayOf("mint", "https://mint.com/", "usd", "sat"))) + val diff = assertNotNull(newer.diffFrom(older)) + assertTrue(diff.mints.isEmpty()) + assertFalse(diff.removesData()) + } + + @Test + fun groupRelaySpellingIsNotARemoval() { + val older = sign(SimpleGroupListEvent.KIND, 100, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com"))) + val newer = sign(SimpleGroupListEvent.KIND, 200, arrayOf(arrayOf("d", ""), arrayOf("group", "g1", "wss://groups.com/"))) + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun geohashCaseIsNotARemoval() { + val older = sign(GeohashListEvent.KIND, 100, arrayOf(arrayOf("g", "9Q8"))) + val newer = sign(GeohashListEvent.KIND, 200, arrayOf(arrayOf("g", "9q8"))) + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } + + @Test + fun droppingDeprecatedProfileAliasesIsNotALoss() { + val older = sign(MetadataEvent.KIND, 100, arrayOf(), """{"name":"vitor","display_name":"Vitor","displayName":"Vitor","username":"vitor"}""") + val newer = sign(MetadataEvent.KIND, 200, arrayOf(), """{"name":"vitor","display_name":"Vitor"}""") + assertFalse(assertNotNull(newer.diffFrom(older)).removesData()) + } +}