diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt index 2b63b36309..f8ca82918a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt @@ -54,9 +54,17 @@ private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favori object FavoriteAppsRegistry { private val KEY = stringPreferencesKey("favorites") + // Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable + // coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and + // offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never + // has to. The relay subscription that warms these favorites keeps the cache fresh. + private val MANIFESTS_KEY = stringPreferencesKey("manifests") + private val _favorites = MutableStateFlow>(emptyList()) val favorites: StateFlow> = _favorites.asStateFlow() + private val manifestCache = MutableStateFlow>(emptyMap()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @Volatile private var appContext: Context? = null @@ -73,11 +81,17 @@ object FavoriteAppsRegistry { val ctx = context.applicationContext appContext = ctx scope.launch { - val json = ctx.favoriteAppsDataStore.data.first()[KEY] - val loaded = if (json != null) decode(json) else emptyList() + val prefs = ctx.favoriteAppsDataStore.data.first() + val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList() // Don't clobber adds made in this session before hydration finished, and don't resurrect // anything the user removed in that same window. update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } } + + // Same race rules for the manifest cache: a cacheManifest() in this session wins over the + // disk copy, and a manifest whose favorite was removed pre-hydration must not come back. + val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap() + updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current } + hydrated = true removedBeforeHydration.clear() } @@ -91,8 +105,22 @@ object FavoriteAppsRegistry { fun remove(id: String) { if (!hydrated) removedBeforeHydration.add(id) update { current -> current.filterNot { it.id == id } } + // Drop the cached manifest too — favorite ids for nsites/napplets are "nostr:". + if (id.startsWith("nostr:")) updateManifests { it - id.removePrefix("nostr:") } } + /** The cached manifest event JSON for a favorited nsite/napplet [coordinate], or null if none. */ + fun cachedManifest(coordinate: String): String? = manifestCache.value[coordinate] + + /** + * Caches the raw manifest event [eventJson] for a favorited nsite/napplet [coordinate] so the next + * launch can resolve it instantly / offline. Write-through; no-ops when the JSON is unchanged. + */ + fun cacheManifest( + coordinate: String, + eventJson: String, + ) = updateManifests { if (it[coordinate] == eventJson) it else it + (coordinate to eventJson) } + /** Replaces the whole list, e.g. after a drag-reorder. */ fun setOrder(newOrder: List) = update { newOrder } @@ -103,6 +131,13 @@ object FavoriteAppsRegistry { persist(encode(next)) } + private inline fun updateManifests(transform: (Map) -> Map) { + val next = transform(manifestCache.value) + if (next == manifestCache.value) return + manifestCache.value = next + persistManifests(encodeManifests(next)) + } + private fun persist(json: String) { val ctx = appContext ?: return scope.launch { @@ -110,6 +145,13 @@ object FavoriteAppsRegistry { } } + private fun persistManifests(json: String) { + val ctx = appContext ?: return + scope.launch { + ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } + } + } + // --- Persistence DTO ------------------------------------------------------------------------ // A flat, type-tagged record so we serialize one concrete shape instead of relying on // polymorphic (sealed) (de)serialization. Mapping to/from the sealed model lives here. @@ -149,4 +191,24 @@ object FavoriteAppsRegistry { private const val TYPE_NOSTR = "nostr" private const val TYPE_URL = "url" + + // --- Manifest cache persistence ------------------------------------------------------------- + // Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated + // shape as the favorites list, so we never serialize a raw polymorphic map. + + @Serializable + private data class ManifestEntry( + val coordinate: String, + val json: String, + ) + + private fun encodeManifests(manifests: Map): String = JsonMapper.toJson(manifests.map { ManifestEntry(it.key, it.value) }) + + private fun decodeManifests(json: String): Map = + try { + JsonMapper.fromJson>(json).associate { it.coordinate to it.json } + } catch (e: Exception) { + Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e) + emptyMap() + } } 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 ddd9699b8c..0a45b55fe1 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 @@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPreloader import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen @@ -254,6 +255,11 @@ fun AppNavigation( AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { BuildNavigation(accountViewModel, nav) + // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) + // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded + // preloader below and the full-screen launcher both need. Not API-gated: every device's + // launcher benefits, and it's the only preload step that runs below API 30. + FavoriteAppManifestPreloader(accountViewModel) // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by // holding their surfaces attached. Below the drawer (drawn by the layout above) and below // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt new file mode 100644 index 0000000000..f5363d6489 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt @@ -0,0 +1,110 @@ +/* + * 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.embed + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +// If relays stay silent this long after launch, fall back to the device-cached manifest so a pinned +// nsite/napplet can still resolve offline. Kept short because the embedded preloader retries on its +// own (~45 s) budget — we only need the manifest in the cache before one of those sweeps lands. +private const val MANIFEST_OFFLINE_FALLBACK_MS = 2_000L + +/** + * Keeps every favorited nsite/napplet (a [FavoriteApp.NostrApp]) resolvable in [LocalCache] the way a + * pinned web app's URL always is — closing the gap where [EmbeddedTabPreloader] could not warm a + * Nostr-app favorite because its manifest event had simply never been fetched (favorites store only a + * `kind:pubkey:dtag` coordinate, and nothing pulled that addressable event in until the user opened + * the napplet/nsite discovery screen). For each favorite this: + * + * 1. subscribes to the manifest's addressable coordinate (via [observeNote] → the EventFinder), so the + * latest version is pulled from the author's relays into [LocalCache]; + * 2. persists whatever version resolves back into the device-local favorites store, so the next cold + * start (or an offline one) has it immediately; and + * 3. if relays stay silent shortly after launch, seeds [LocalCache] from that cached copy. + * + * Mounted once in the logged-in shell, independent of the API-30 embedded-surface gate: the + * full-screen launcher ([com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher]) benefits from a + * resolved manifest on every device too. Draws nothing; it just drives acquisition. + */ +@Composable +fun FavoriteAppManifestPreloader(accountViewModel: AccountViewModel) { + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val coordinates = + remember(favorites) { + favorites.filterIsInstance().map { it.coordinate } + } + + coordinates.forEach { coordinate -> + key(coordinate) { + WatchFavoriteManifest(coordinate, accountViewModel) + } + } +} + +@Composable +private fun WatchFavoriteManifest( + coordinate: String, + accountViewModel: AccountViewModel, +) { + val note = remember(coordinate) { LocalCache.checkGetOrCreateAddressableNote(coordinate) } ?: return + + // Drives the relay fetch while the manifest is missing AND observes LocalCache for the resolved + // (or any newer) version arriving from relays. + val noteState by observeNote(note, accountViewModel) + val event = noteState.note.event + + // Persist the freshest manifest we have so the next launch resolves instantly. Keyed on the event + // id so a republished manifest (new version → new id) replaces the cached copy; the very first + // value also covers the "just favorited it" case without touching the add sites. + LaunchedEffect(event?.id) { + val resolved = event ?: return@LaunchedEffect + withContext(Dispatchers.IO) { + FavoriteAppsRegistry.cacheManifest(coordinate, resolved.toJson()) + } + } + + // Offline / slow-relay fallback: if nothing has arrived shortly after mount, seed LocalCache from + // the cached copy. Guarded twice so we never overwrite a version the relays did deliver, and + // consumed with wasVerified=false so the cached event's signature is re-checked before we trust it. + LaunchedEffect(coordinate) { + if (LocalCache.getAddressableNoteIfExists(coordinate)?.event != null) return@LaunchedEffect + delay(MANIFEST_OFFLINE_FALLBACK_MS) + if (LocalCache.getAddressableNoteIfExists(coordinate)?.event != null) return@LaunchedEffect + withContext(Dispatchers.IO) { + val cached = FavoriteAppsRegistry.cachedManifest(coordinate) ?: return@withContext + Event.fromJsonOrNull(cached)?.let { LocalCache.justConsume(it, null, false) } + } + } +}