feat: preload and cache pinned nsite/napplet manifests

Pinned web apps in the bottom nav warm reliably because EmbeddedTabFactory
only needs their URL, but a pinned nsite/napplet (FavoriteApp.NostrApp) could
not warm: favorites store only a kind:pubkey:dtag coordinate, and nothing
pulled that addressable manifest into LocalCache until the user opened the
napplet/nsite discovery screen. So embedParams() returned null and the
EmbeddedTabPreloader gave up.

Add FavoriteAppManifestPreloader, mounted once in the logged-in shell
(independent of the API-30 embedded-surface gate, since the full-screen
launcher benefits too). For each NostrApp favorite it drives the existing
EventFinder (via observeNote) to fetch the manifest's coordinate into
LocalCache, so the preloader and launcher can resolve it.

Also cache the resolved manifest event JSON device-locally in
FavoriteAppsRegistry (a second DataStore key, same single-key shape as the
favorites list) and seed LocalCache from it when relays stay silent shortly
after launch, so a pinned nsite/napplet resolves instantly and offline on the
next cold start. The cached copy is re-verified (wasVerified=false) before it
enters the cache, and refreshed whenever a newer manifest arrives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LroBCry1UiXWf9Y4fk4b9h
This commit is contained in:
Claude
2026-06-27 01:43:38 +00:00
parent 23a1c8af13
commit 7340d93f0c
3 changed files with 180 additions and 2 deletions
@@ -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<List<FavoriteApp>>(emptyList())
val favorites: StateFlow<List<FavoriteApp>> = _favorites.asStateFlow()
private val manifestCache = MutableStateFlow<Map<String, String>>(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:<coordinate>".
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<FavoriteApp>) = update { newOrder }
@@ -103,6 +131,13 @@ object FavoriteAppsRegistry {
persist(encode(next))
}
private inline fun updateManifests(transform: (Map<String, String>) -> Map<String, String>) {
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, String>): String = JsonMapper.toJson(manifests.map { ManifestEntry(it.key, it.value) })
private fun decodeManifests(json: String): Map<String, String> =
try {
JsonMapper.fromJson<List<ManifestEntry>>(json).associate { it.coordinate to it.json }
} catch (e: Exception) {
Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e)
emptyMap()
}
}
@@ -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.
@@ -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<FavoriteApp.NostrApp>().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) }
}
}
}