diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index c3445013ca..7266fdc6cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 26697a4fcb..2bfc9ff3be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index cd6fb36b17..45cfd84f05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 7f98885be1..c6badf72e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 42c7f91d64..14d81fdaee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index b51d1059b5..30dddb074a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 06f038fb22..d4e10ff587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 8d0c221e5a..424c15c1bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -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:` diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt index 48c143ee19..8c68f970f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt @@ -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 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 index b38eee524f..9edabf3bdb 100644 --- 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 @@ -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().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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index b29f7462c2..c4e69e44f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt index b85cf1126c..0afea73440 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt @@ -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)) } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index 1067fce67e..552dfc6441 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -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())) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index d2d91c37a6..38b405e133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -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, "") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt index e9988b23e7..3d4f010948 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt @@ -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 - 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, + private val scope: CoroutineScope, +) { private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt index ad9d03d325..74c1480770 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt @@ -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 - 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, + private val scope: CoroutineScope, +) { private val _favorites = MutableStateFlow>(emptyList()) val favorites: StateFlow> = _favorites.asStateFlow() private val manifestCache = MutableStateFlow>(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() + private val removedBeforeHydration = ConcurrentSet() - /** 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) -> Map) { 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, + 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" + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt new file mode 100644 index 0000000000..108389ccfb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt @@ -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 = 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 { + 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 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 }) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt new file mode 100644 index 0000000000..1d4e8f52d1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt @@ -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, + ) + } +}