refactor(commons): move the favorites and browser-history registries out of the app

FavoriteAppsRegistry and BrowserHistoryRegistry were the two stores that
2539e510 repointed at AppPreferenceStores without relocating. That commit's
scope was removing the Context.preferencesDataStore delegates, so these kept
sitting in amethyst/ — by inertia, not because anything platform-specific held
them there.

Nothing in either was Android. The one real tie was that they reached out for
their store:

    private val favoriteAppsDataStore: DataStore<Preferences>
        get() = Amethyst.instance.appStores.getDataStore("favorite_apps")

Every store that already lives in commons takes its DataStore instead, for the
reason DataStoreSearchHistoryStorage documents: DataStore refuses a second live
instance on a path that already has one, so the caller's holder has to stay the
single registry. Both are classes taking (store, scope) now, and the Android
side does the binding in AppModules from appStores.getDataStore(FILE_NAME) and
applicationIOScope. File names are unchanged, so nothing migrates.

The Context parameter turned out to be dead. It was written once in init() and
then only read as `appContext ?: return` — a has-init-run gate that happened to
be typed Context?, never used as a Context. It is a Boolean now. The other
platform call, System.currentTimeMillis() in record(), is TimeUtils.nowMillis().
ConcurrentHashMap.newKeySet() becomes commons' ConcurrentSet, which exists for
exactly this (lock-striped on JVM, lock-guarded on iOS).

BrowserHistoryRegistry's `hydrated` flag is gone: it was assigned and never
read, unlike FavoriteAppsRegistry's, which gates the removal tombstones.

The point of the move is that the disk lifecycle is now testable — as objects
reaching into a singleton these had no unit coverage at all. 14 new tests, the
ones worth naming being the two sides of the hydration window: a favorite
removed after init() but before the merge lands must not be resurrected by it,
and an add made in that same window must not be dropped by it. Both are driven
on the test scheduler, so the window is a state the test controls rather than
races. Also pinned: nothing is written before init() (without that gate a write
in the window flushes a partial list over the stored one), a corrupt file
hydrates empty, the history cap at 500, and that a blank title on a revisit
does not overwrite the name the user recognises.

Writing those tests surfaced the single-instance rule the hard way: the first
restart tests opened a second DataStore on a live path and read an empty store,
which looks exactly like data loss. Each test session now owns its Job and is
cancelled before the next opens — the same rule production follows by keeping
one instance per file.

Verified: :commons:jvmTest, :commons:verifyKmpPurity,
:commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
Claude
2026-09-25 19:37:22 +00:00
parent e21b93c31b
commit f0c6695521
18 changed files with 636 additions and 157 deletions
@@ -25,9 +25,7 @@ import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
@@ -143,10 +141,10 @@ class Amethyst : Application() {
WorkerThreadPriorityGovernor.start(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
instance.favoriteApps.init()
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
BrowserHistoryRegistry.init(this)
instance.browserHistory.init()
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
@@ -27,8 +27,10 @@ import android.os.SystemClock
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.UiSettings
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -887,6 +889,16 @@ class AppModules(
// Display + relay info for connected NIP-46 remote-signer clients.
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) }
// The device-local favorite-apps list behind the bottom bar, the Favorite Apps grid and the
// browser launcher, plus the browser's visit history behind the omnibox suggestions. Both live in
// commons and take their store and scope from here — that is the whole of their Android binding.
//
// Main process only: the keyless `:napplet` sandbox never builds AppModules, so it never builds
// these either. One instance each, so DataStore only ever sees one live reader per file.
val favoriteApps by lazy { FavoriteAppsRegistry(appStores.getDataStore(FavoriteAppsRegistry.FILE_NAME), applicationIOScope) }
val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -97,7 +97,7 @@ object FavoriteAppLauncher {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val isFavorite = Amethyst.instance.favoriteApps.isFavorite("url:$url")
val intent =
NappletBrowserActivity
.intent(
@@ -42,9 +42,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
@@ -203,8 +201,9 @@ class NappletBrokerService : Service() {
if (msg.what == NappletIpc.MSG_RECORD_HISTORY) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true
BrowserHistoryRegistry.init(applicationContext)
BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
val history = Amethyst.instance.browserHistory
history.init()
history.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
return true
}
@@ -223,12 +222,13 @@ class NappletBrokerService : Service() {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url }
FavoriteAppsRegistry.init(applicationContext)
val favorites = Amethyst.instance.favoriteApps
favorites.init()
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
if (favorites.isFavorite(id)) {
favorites.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
}
return true
}
@@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -112,7 +112,8 @@ fun AppBottomBar(
// Favorite entries in the unified list resolve to a live favorite for their icon/label and to an
// embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet
// surface), so such a tab swaps in place rather than launching an activity from the bottom row.
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isKeyboardState by keyboardAsState()
if (isKeyboardState == KeyboardState.Closed) {
@@ -36,9 +36,9 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
@@ -58,7 +58,8 @@ fun AppNavigationRail(
) {
val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
.collectAsStateWithLifecycle()
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
val reselectCoordinator = LocalTabReselectCoordinator.current
@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryEntry
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
@@ -101,11 +102,8 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -154,8 +152,10 @@ private fun BrowserLauncher(
) {
val context = LocalContext.current
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val history by Amethyst.instance.browserHistory.history
.collectAsStateWithLifecycle()
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of
@@ -235,10 +235,10 @@ private fun BrowserLauncher(
label: String,
) {
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
if (Amethyst.instance.favoriteApps.isFavorite(id)) {
Amethyst.instance.favoriteApps.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
}
}
@@ -290,7 +290,7 @@ private fun BrowserLauncher(
historyUrls = historyUrls,
onOpen = { open(it.url) },
onToggleFavorite = { toggleFavorite(it.url, it.label) },
onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) },
onRemoveFromHistory = { Amethyst.instance.browserHistory.remove(it) },
modifier = contentModifier,
)
else -> {
@@ -306,11 +306,11 @@ private fun BrowserLauncher(
nsites = followedNsites,
napplets = followedNapplets,
onOpenApp = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
onRemoveApp = { FavoriteAppsRegistry.remove(it.id) },
onAddApp = { FavoriteAppsRegistry.add(it) },
onRemoveApp = { Amethyst.instance.favoriteApps.remove(it.id) },
onAddApp = { Amethyst.instance.favoriteApps.add(it) },
onOpenUrl = { open(it) },
onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) },
onRemoveRecent = { BrowserHistoryRegistry.remove(it) },
onRemoveRecent = { Amethyst.instance.browserHistory.remove(it) },
modifier = contentModifier,
)
}
@@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -110,7 +109,8 @@ private fun EmbeddedWebAppTab(
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
@@ -147,10 +147,10 @@ private fun EmbeddedWebAppTab(
isFavorite = isFavorite,
onFavorite = {
val favId = "url:$currentUrl"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
if (Amethyst.instance.favoriteApps.isFavorite(favId)) {
Amethyst.instance.favoriteApps.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
}
},
// NIP-07 grants for a plain web client are keyed per visited origin as `browser:<origin>`
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import kotlinx.coroutines.CoroutineScope
@@ -113,7 +113,9 @@ object EmbeddedTabPreloadSweeper {
NappletNetworkRegistry.awaitReady()
var attempt = 0
while (isActive) {
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
val byId =
Amethyst.instance.favoriteApps.favorites.value
.associateBy { it.id }
var stillPending = false
for (id in favoriteIds) {
val app = byId[id] ?: continue
@@ -26,9 +26,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -60,7 +60,8 @@ private const val MANIFEST_OFFLINE_FALLBACK_MS = 2_000L
*/
@Composable
fun FavoriteAppManifestPreloader(accountViewModel: AccountViewModel) {
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val coordinates =
remember(favorites) {
favorites.filterIsInstance<FavoriteApp.NostrApp>().map { it.coordinate }
@@ -91,7 +92,7 @@ private fun WatchFavoriteManifest(
LaunchedEffect(event?.id) {
val resolved = event ?: return@LaunchedEffect
withContext(Dispatchers.IO) {
FavoriteAppsRegistry.cacheManifest(coordinate, resolved.toJson())
Amethyst.instance.favoriteApps.cacheManifest(coordinate, resolved.toJson())
}
}
@@ -103,7 +104,7 @@ private fun WatchFavoriteManifest(
delay(MANIFEST_OFFLINE_FALLBACK_MS)
if (LocalCache.getAddressableNoteIfExists(coordinate)?.event != null) return@LaunchedEffect
withContext(Dispatchers.IO) {
val cached = FavoriteAppsRegistry.cachedManifest(coordinate) ?: return@withContext
val cached = Amethyst.instance.favoriteApps.cachedManifest(coordinate) ?: return@withContext
Event.fromJsonOrNull(cached)?.let { LocalCache.justConsume(it, null, false) }
}
}
@@ -59,6 +59,7 @@ 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.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -74,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -94,7 +94,8 @@ fun FavoriteAppsScreen(
) {
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
val context = LocalContext.current
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
// Fetch favorited nsite/napplet manifests up front so a tap launches immediately instead of showing
// "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
@@ -126,7 +127,7 @@ fun FavoriteAppsScreen(
FavoriteAppsGrid(
apps = apps,
onOpen = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
onRemove = { FavoriteAppsRegistry.remove(it.id) },
onRemove = { Amethyst.instance.favoriteApps.remove(it.id) },
modifier =
Modifier
.fillMaxSize()
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.favorite_app_add
import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
/**
* A star toggle that pins/unpins an nsite or napplet (a [FavoriteApp.NostrApp]) by its addressable
@@ -47,16 +47,17 @@ fun FavoriteToggleButton(
label: String,
iconUrl: String? = null,
) {
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val id = "nostr:$coordinate"
val isFavorite = remember(apps, id) { apps.any { it.id == id } }
IconButton(
onClick = {
if (isFavorite) {
FavoriteAppsRegistry.remove(id)
Amethyst.instance.favoriteApps.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl))
Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl))
}
},
) {
@@ -54,6 +54,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
@@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
@@ -147,7 +147,8 @@ private fun EmbeddedNostrAppTab(
var canGoBack by remember { mutableStateOf(false) }
var showAccess by remember { mutableStateOf(false) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val apps by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
val controller =
@@ -182,10 +183,10 @@ private fun EmbeddedNostrAppTab(
isFavorite = isFavorite,
onFavorite = {
val favId = "nostr:$coordinate"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
if (Amethyst.instance.favoriteApps.isFavorite(favId)) {
Amethyst.instance.favoriteApps.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
}
},
)
@@ -67,6 +67,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories
import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryAvatar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryDisplay
@@ -498,7 +498,8 @@ private fun PickerChildren(
) {
when (item) {
NavBarItem.BROWSER -> {
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
if (favorites.isEmpty()) {
EmptyChildHint(Res.string.bottom_bar_settings_no_favorites)
} else {
@@ -817,7 +818,8 @@ private fun rememberPinnedVisual(
PinnedVisual.Glyph(def?.icon ?: MaterialSymbols.Apps, def?.let { stringRes(it.labelRes) } ?: "")
}
is BottomBarEntry.Favorite -> {
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favorites by Amethyst.instance.favoriteApps.favorites
.collectAsStateWithLifecycle()
val app = favorites.firstOrNull { it.id == entry.favoriteId }
if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "")
}
@@ -18,36 +18,23 @@
* 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.favorites
package com.vitorpamplona.amethyst.commons.browser
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
/**
* The browser-history file, on the app-wide holder rather than a `Context` delegate.
* Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox.
*/
private val browserHistoryDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("browser_history")
import kotlin.concurrent.Volatile
/**
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
@@ -65,39 +52,40 @@ data class BrowserHistoryEntry(
/**
* The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites.
*
* **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host
* (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from
* the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded
* to [MAX_ENTRIES] most-recent entries.
* **Only pages that actually loaded land here.** [record] is meant to be called on a *successful*
* main-frame page-finish — never from the address bar as the user types — so misspelled/never-resolved
* hosts never pollute the list. Bounded to [MAX_ENTRIES] most-recent entries. On Android the call is
* relayed from the `:napplet` browser host over IPC through `NappletBrokerService`.
*
* Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox
* never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for
* synchronous Compose reads, with write-through persistence to a DataStore on a background scope.
* Same shape as [com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry]: an authoritative
* in-memory [StateFlow] for synchronous Compose reads, write-through persistence to [store] on [scope],
* and the [DataStore] handed in rather than reached for, so the caller's store holder stays the single
* registry and nothing here depends on a front end.
*
* One instance per process. On Android the launcher/omnibox in the **main** process own it; the keyless
* `:napplet` sandbox never builds one.
*/
object BrowserHistoryRegistry {
private val KEY = stringPreferencesKey("history")
private const val MAX_ENTRIES = 500
class BrowserHistoryRegistry(
private val store: DataStore<Preferences>,
private val scope: CoroutineScope,
) {
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Gates persistence until init() has been called: writing before hydration has been scheduled
// would flush a partial list over the stored one. Set synchronously in init(), so the merge it
// launches still persists whatever the session recorded in the meantime.
@Volatile private var started = false
@Volatile private var appContext: Context? = null
@Volatile private var hydrated = false
/** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
/** Hydrates the on-disk list into [history]. Idempotent. */
fun init() {
if (started) return
started = true
scope.launch {
val json = browserHistoryDataStore.data.first()[KEY]
val json = store.data.first()[KEY]
val loaded = if (json != null) decode(json) else emptyList()
// Merge disk under anything already recorded this session (session wins, newest-first).
update { current -> dedupeNewestFirst(current + loaded) }
hydrated = true
}
}
@@ -110,7 +98,7 @@ object BrowserHistoryRegistry {
title: String,
) {
val host = OmniboxInput.hostOf(url) ?: url
val now = System.currentTimeMillis()
val now = TimeUtils.nowMillis()
update { current ->
val existing = current.firstOrNull { it.url == url }
val entry =
@@ -146,9 +134,9 @@ object BrowserHistoryRegistry {
}
private fun persist(json: String) {
appContext ?: return
if (!started) return
scope.launch {
browserHistoryDataStore.edit { it[KEY] = json }
store.edit { it[KEY] = json }
}
}
@@ -161,4 +149,12 @@ object BrowserHistoryRegistry {
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
emptyList()
}
companion object {
/** Same file the `Context.preferencesDataStore("browser_history")` delegate resolved to. */
const val FILE_NAME = "browser_history"
private val KEY = stringPreferencesKey("history")
private const val MAX_ENTRIES = 500
}
}
@@ -18,89 +18,77 @@
* 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.favorites
package com.vitorpamplona.amethyst.commons.favorites
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.util.ConcurrentSet
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
/**
* The favorite-apps file, on the app-wide holder rather than a `Context` delegate.
* Same path the delegate resolved to, so nothing migrates.
*
* Main process only: [Amethyst.instance] is deliberately unset in the
* `:napplet` sandbox.
*/
private val favoriteAppsDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("favorite_apps")
import kotlin.concurrent.Volatile
/**
* The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom
* bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated
* by [FavoriteApp.id].
*
* Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never
* touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it
* synchronously, with write-through persistence to a DataStore on a background scope. The list is
* stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key
* per entry).
* An in-memory [StateFlow] is authoritative for the session so Compose can observe it synchronously,
* with write-through persistence to [store] on [scope]. The list is stored as a single JSON array
* under one key (small, bounded, hand-curated data — no need for one key per entry).
*
* Takes its [DataStore] rather than reaching for one, for the same reason
* `DataStoreSearchHistoryStorage` does: DataStore refuses a second live instance on a path that
* already has one, so the caller's store holder stays the single registry. That is also what keeps
* this class off any one front end — the Android app builds it in `AppModules` from
* `appStores.getDataStore(FILE_NAME)`, and nothing here knows about `Context` or the app singleton.
*
* One instance per process. On Android the launcher/UI in the **main** process own it; the keyless
* `:napplet` sandbox never builds one.
*/
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")
class FavoriteAppsRegistry(
private val store: DataStore<Preferences>,
private val scope: CoroutineScope,
) {
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)
// Gates persistence until init() has been called: writing before hydration has been scheduled
// would flush a partial list over the stored one. Set synchronously in init(), so the merge it
// launches still persists whatever the session added in the meantime.
@Volatile private var started = false
@Volatile private var appContext: Context? = null
// Hydration runs async on a background scope, so the user can add/remove before the disk list
// merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
// Hydration runs async on [scope], so the user can add/remove before the disk list merges in.
// [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
// resurrect a just-deleted favorite from disk.
@Volatile private var hydrated = false
private val removedBeforeHydration = ConcurrentHashMap.newKeySet<String>()
private val removedBeforeHydration = ConcurrentSet<String>()
/** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
/** Hydrates the on-disk list into [favorites]. Idempotent. */
fun init() {
if (started) return
started = true
scope.launch {
val prefs = favoriteAppsDataStore.data.first()
val prefs = store.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 } }
update { current -> (loaded.filterNot { removedBeforeHydration.contains(it.id) } + 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 }
updateManifests { current -> loadedManifests.filterKeys { !removedBeforeHydration.contains("nostr:$it") } + current }
hydrated = true
removedBeforeHydration.clear()
@@ -138,27 +126,23 @@ object FavoriteAppsRegistry {
val next = transform(_favorites.value)
if (next == _favorites.value) return
_favorites.value = next
persist(encode(next))
persist(KEY, 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))
persist(MANIFESTS_KEY, encodeManifests(next))
}
private fun persist(json: String) {
appContext ?: return
private fun persist(
key: Preferences.Key<String>,
json: String,
) {
if (!started) return
scope.launch {
favoriteAppsDataStore.edit { it[KEY] = json }
}
}
private fun persistManifests(json: String) {
appContext ?: return
scope.launch {
favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
store.edit { it[key] = json }
}
}
@@ -199,9 +183,6 @@ object FavoriteAppsRegistry {
emptyList()
}
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.
@@ -221,4 +202,20 @@ object FavoriteAppsRegistry {
Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e)
emptyMap()
}
companion object {
/** Same file the `Context.preferencesDataStore("favorite_apps")` delegate resolved to. */
const val FILE_NAME = "favorite_apps"
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 const val TYPE_NOSTR = "nostr"
private const val TYPE_URL = "url"
}
}
@@ -0,0 +1,218 @@
/*
* 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.browser
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.Path.Companion.toOkioPath
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* The visit history's ranking inputs and its bound, neither of which had coverage while this was an
* Android-side `object`. The omnibox ranks on [BrowserHistoryEntry.visitCount] and
* [BrowserHistoryEntry.lastVisitedAt], so a bump that does not bump is a silently wrong suggestion
* order rather than a crash.
*/
class BrowserHistoryRegistryTest {
@get:Rule
val folder = TemporaryFolder()
private var seq = 0
private fun newFile() = File(folder.root, "browser_history_${seq++}.preferences_pb")
private fun store(
scope: CoroutineScope,
file: File,
): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() })
/**
* One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released
* when the session is cancelled: DataStore refuses a second live instance on a path that already
* has one, which is exactly why production keeps a single instance per file in the store holder.
* A restart test that skipped this would read an empty store and look like data loss.
*/
private fun TestScope.session(file: File): Pair<BrowserHistoryRegistry, CoroutineScope> {
val scope = CoroutineScope(coroutineContext + Job())
return BrowserHistoryRegistry(store(scope, file), scope) to scope
}
/** A revisit is a bump, not a second row — this is what makes frecency mean anything. */
@Test
fun revisitingAUrlBumpsTheCountInsteadOfAddingARow() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
registry.record("https://example.com/a", "A")
registry.record("https://example.com/a", "A again")
assertEquals("one row for one url", 1, registry.history.value.size)
assertEquals(
"visit count bumped",
2,
registry.history.value
.single()
.visitCount,
)
assertEquals(
"title refreshed",
"A again",
registry.history.value
.single()
.title,
)
}
/** A page that finishes loading with no <title> must not blank out the name the user recognises. */
@Test
fun aBlankTitleOnARevisitKeepsTheOldOne() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
registry.record("https://example.com/a", "Real Title")
registry.record("https://example.com/a", " ")
assertEquals(
"the blank did not overwrite it",
"Real Title",
registry.history.value
.single()
.title,
)
}
/** Most recent first, because that is the order the omnibox shows them in. */
@Test
fun theMostRecentVisitLeads() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
registry.record("https://first.example", "First")
registry.record("https://second.example", "Second")
assertEquals(
"newest at the front",
listOf("https://second.example", "https://first.example"),
registry.history.value.map { it.url },
)
}
/** The bound is what stops an unbounded JSON blob being rewritten on every page load. */
@Test
fun historyIsCappedAtFiveHundredEntries() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
repeat(505) { registry.record("https://example.com/page$it", "Page $it") }
assertEquals("capped", 500, registry.history.value.size)
assertEquals(
"and it is the oldest that fell off",
"https://example.com/page504",
registry.history.value
.first()
.url,
)
assertTrue("page0 is gone", registry.history.value.none { it.url == "https://example.com/page0" })
}
@Test
fun historySurvivesARestart() =
runTest {
val file = newFile()
val (first, firstScope) = session(file)
first.init()
advanceUntilIdle()
first.record("https://example.com/a", "A")
advanceUntilIdle()
firstScope.cancel()
val (second, _) = session(file)
second.init()
advanceUntilIdle()
assertEquals("hydrated from disk", listOf("https://example.com/a"), second.history.value.map { it.url })
assertEquals(
"with its count",
1,
second.history.value
.single()
.visitCount,
)
}
/** Clearing is a privacy action: it has to reach disk, not just the in-memory flow. */
@Test
fun clearingEmptiesTheStoredHistoryToo() =
runTest {
val file = newFile()
val (first, firstScope) = session(file)
first.init()
advanceUntilIdle()
first.record("https://example.com/a", "A")
advanceUntilIdle()
first.clear()
advanceUntilIdle()
firstScope.cancel()
val (second, _) = session(file)
second.init()
advanceUntilIdle()
assertTrue("nothing came back", second.history.value.isEmpty())
}
@Test
fun removingOneUrlLeavesTheRest() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
registry.record("https://keep.example", "Keep")
registry.record("https://drop.example", "Drop")
registry.remove("https://drop.example")
assertEquals("only the one", listOf("https://keep.example"), registry.history.value.map { it.url })
}
}
@@ -0,0 +1,248 @@
/*
* 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.favorites
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.Path.Companion.toOkioPath
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* The registry's disk lifecycle, which had no coverage while it was an Android-side `object` reaching
* into `Amethyst.instance` for its store. Taking the [DataStore] as a constructor argument is what
* makes these reachable.
*
* Every test drives the registry on the test scheduler, so "hydration has not finished yet" is a state
* the test controls rather than races: [advanceUntilIdle] is the only thing that lets the coroutine
* [FavoriteAppsRegistry.init] launches actually run.
*/
class FavoriteAppsRegistryTest {
@get:Rule
val folder = TemporaryFolder()
private var seq = 0
private fun newFile() = File(folder.root, "favorite_apps_${seq++}.preferences_pb")
private fun store(
scope: CoroutineScope,
file: File,
): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() })
/**
* One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released
* when the session is cancelled: DataStore refuses a second live instance on a path that already
* has one, which is exactly why production keeps a single instance per file in the store holder.
* A restart test that skipped this would read an empty store and look like data loss.
*/
private fun TestScope.session(file: File): Pair<FavoriteAppsRegistry, CoroutineScope> {
val scope = CoroutineScope(coroutineContext + Job())
return FavoriteAppsRegistry(store(scope, file), scope) to scope
}
private fun web(
url: String,
label: String = url,
) = FavoriteApp.WebApp(url, label, addedAt = 1L)
/** What the user actually notices: a favorite added in one session is there in the next. */
@Test
fun aFavoriteSurvivesARestart() =
runTest {
val file = newFile()
val (first, firstScope) = session(file)
first.init()
advanceUntilIdle()
first.add(web("https://example.com"))
advanceUntilIdle()
firstScope.cancel()
val (second, _) = session(file)
second.init()
advanceUntilIdle()
assertEquals(
"the favorite written by the first session is what the second one hydrates",
listOf("url:https://example.com"),
second.favorites.value.map { it.id },
)
}
/**
* The tombstone. Removing between init() and the merge landing must win, or the disk copy
* resurrects a favorite the user just deleted — and then persists it again.
*/
@Test
fun hydrationDoesNotResurrectAFavoriteRemovedBeforeItFinished() =
runTest {
val file = newFile()
val (seeded, seededScope) = session(file)
seeded.init()
advanceUntilIdle()
seeded.add(web("https://gone.example"))
seeded.add(web("https://kept.example"))
advanceUntilIdle()
seededScope.cancel()
val (reopened, _) = session(file)
reopened.init()
// Still inside the window: init() launched the merge but nothing has run it yet.
reopened.remove("url:https://gone.example")
advanceUntilIdle()
assertEquals(
"the removal beat the merge and must survive it",
listOf("url:https://kept.example"),
reopened.favorites.value.map { it.id },
)
}
/** The other side of the same window: an add made before the merge must not be dropped by it. */
@Test
fun hydrationKeepsAnAddMadeBeforeItFinished() =
runTest {
val file = newFile()
val (seeded, seededScope) = session(file)
seeded.init()
advanceUntilIdle()
seeded.add(web("https://ondisk.example"))
advanceUntilIdle()
seededScope.cancel()
val (reopened, _) = session(file)
reopened.init()
reopened.add(web("https://thissession.example"))
advanceUntilIdle()
assertEquals(
"both the disk copy and the pre-merge add are present",
setOf("url:https://ondisk.example", "url:https://thissession.example"),
reopened.favorites.value
.map { it.id }
.toSet(),
)
}
/**
* Persistence is gated on init(). Without the gate a write in that window flushes a list that has
* not merged with disk yet, which is the stored list being replaced by a partial one.
*/
@Test
fun nothingIsWrittenBeforeInit() =
runTest {
val file = newFile()
val (registry, writerScope) = session(file)
registry.add(web("https://notpersisted.example"))
advanceUntilIdle()
assertEquals(
"the add is live in memory",
listOf("url:https://notpersisted.example"),
registry.favorites.value.map { it.id },
)
writerScope.cancel()
advanceUntilIdle()
val readerScope = CoroutineScope(coroutineContext + Job())
assertNull(
"but nothing reached disk, because init() never ran",
store(readerScope, file).data.first()[stringPreferencesKey("favorites")],
)
readerScope.cancel()
}
/** A favorite's cached manifest must not outlive the favorite. */
@Test
fun removingANostrFavoriteDropsItsCachedManifest() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
val coordinate = "31990:pubkey:slug"
registry.add(FavoriteApp.NostrApp(coordinate, "An App", addedAt = 1L))
registry.cacheManifest(coordinate, """{"kind":31990}""")
assertEquals("cached while favorited", """{"kind":31990}""", registry.cachedManifest(coordinate))
registry.remove("nostr:$coordinate")
advanceUntilIdle()
assertNull("and gone with the favorite", registry.cachedManifest(coordinate))
}
/** Garbage on disk must degrade to an empty list, never take the launcher down with it. */
@Test
fun aCorruptStoredListHydratesAsEmpty() =
runTest {
val file = newFile()
val seedScope = CoroutineScope(coroutineContext + Job())
store(seedScope, file).edit { it[stringPreferencesKey("favorites")] = "}not json[" }
advanceUntilIdle()
seedScope.cancel()
advanceUntilIdle()
val (registry, _) = session(file)
registry.init()
advanceUntilIdle()
assertTrue("decode failed softly", registry.favorites.value.isEmpty())
}
/** Ids are the identity: adding the same app twice is a no-op, not a duplicate row. */
@Test
fun addingTheSameAppTwiceKeepsOneEntry() =
runTest {
val (registry, _) = session(newFile())
registry.init()
advanceUntilIdle()
registry.add(web("https://example.com", "First"))
registry.add(web("https://example.com", "Second"))
assertEquals("de-duplicated by id", 1, registry.favorites.value.size)
assertEquals(
"and the first one won",
"First",
registry.favorites.value
.single()
.label,
)
}
}