From 0606566238c11530d161f232f42f740fc8d092a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 14:40:51 +0000 Subject: [PATCH] feat: pin favorite web apps as embedded, swap-in-place bottom-bar tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the favorites system so a favorite can live in the bottom row as a real tab instead of only launching a full-screen activity. - FavoriteAppsRegistry gains a pinned-ids set (persisted device-locally, alongside the favorites list). Only WebUrl favorites are pinnable today — they're the ones that embed in-process — so a pinned tab always swaps in place and never launches an activity from the bottom row. Removing a favorite unpins it. - Route.FavoriteWebApp(url) + FavoriteWebAppScreen render the embedded :napplet browser surface as an in-app tab: the app bottom bar stays, so switching to/from it is an ordinary tab swap. A pop-out action hands the same URL to the full-screen BrowserHostActivity for users who want it as its own window. - AppBottomBar appends pinned favorites as tabs after the built-in items, navigating via navBottomBar (marked a tab root, so the bar stays). - The Favorite Apps grid gains a Pin/Unpin action for WebUrl favorites. Known follow-ups (intentionally out of this commit): NostrApp favorites can't embed as tabs yet (they need an embedded nsite/napplet surface in nappletHost), and embedded tabs rebuild on return rather than staying warm. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN --- .../favorites/FavoriteAppsRegistry.kt | 65 ++++++-- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/AppBottomBar.kt | 47 +++++- .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../loggedIn/browser/FavoriteWebAppScreen.kt | 155 ++++++++++++++++++ .../loggedIn/favorites/FavoriteAppsScreen.kt | 18 ++ amethyst/src/main/res/values/strings.xml | 2 + 7 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt index 7165222ad0..b9a4d267de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.favorites import android.content.Context import android.util.Log +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -52,24 +53,37 @@ private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favori */ object FavoriteAppsRegistry { private val KEY = stringPreferencesKey("favorites") + private val PINNED_KEY = stringPreferencesKey("pinned") private val _favorites = MutableStateFlow>(emptyList()) val favorites: StateFlow> = _favorites.asStateFlow() + // Ordered ids of favorites the user pinned as bottom-bar tabs. Only embeddable favorites + // (currently WebUrl) are ever pinned, so a pinned tab always swaps in place — never launches an + // activity from the bottom row. + private val _pinnedIds = MutableStateFlow>(emptyList()) + val pinnedIds: StateFlow> = _pinnedIds.asStateFlow() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @Volatile private var appContext: Context? = null - /** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */ + /** Binds the app context and hydrates the on-disk lists into [favorites] / [pinnedIds]. Idempotent. */ fun init(context: Context) { if (appContext != null) return val ctx = context.applicationContext appContext = ctx scope.launch { - val json = ctx.favoriteAppsDataStore.data.first()[KEY] ?: return@launch - val loaded = decode(json) - // Don't clobber adds made in this session before hydration finished. - update { current -> (loaded + current).distinctBy { it.id } } + val prefs = ctx.favoriteAppsDataStore.data.first() + prefs[KEY]?.let { json -> + val loaded = decode(json) + // Don't clobber adds made in this session before hydration finished. + update { current -> (loaded + current).distinctBy { it.id } } + } + prefs[PINNED_KEY]?.let { json -> + val loaded = decodeIds(json) + updatePinned { current -> (loaded + current).distinct() } + } } } @@ -78,23 +92,46 @@ object FavoriteAppsRegistry { /** Adds [app] to the end if not already present (by [FavoriteApp.id]). */ fun add(app: FavoriteApp) = update { current -> if (current.any { it.id == app.id }) current else current + app } - fun remove(id: String) = update { current -> current.filterNot { it.id == id } } + fun remove(id: String) { + update { current -> current.filterNot { it.id == id } } + // A removed favorite can't stay pinned to the bottom bar. + setPinned(id, false) + } /** Replaces the whole list, e.g. after a drag-reorder. */ fun setOrder(newOrder: List) = update { newOrder } + fun isPinned(id: String): Boolean = _pinnedIds.value.contains(id) + + /** Pins or unpins [id] as a bottom-bar tab (appended in pin order). */ + fun setPinned( + id: String, + pinned: Boolean, + ) = updatePinned { current -> + if (pinned) (current + id).distinct() else current - id + } + private inline fun update(transform: (List) -> List) { val next = transform(_favorites.value) if (next == _favorites.value) return _favorites.value = next - persist(next) + persist(KEY, encode(next)) } - private fun persist(list: List) { + private inline fun updatePinned(transform: (List) -> List) { + val next = transform(_pinnedIds.value) + if (next == _pinnedIds.value) return + _pinnedIds.value = next + persist(PINNED_KEY, JsonMapper.toJson(next)) + } + + private fun persist( + key: Preferences.Key, + json: String, + ) { val ctx = appContext ?: return - val json = encode(list) scope.launch { - ctx.favoriteAppsDataStore.edit { it[KEY] = json } + ctx.favoriteAppsDataStore.edit { it[key] = json } } } @@ -134,6 +171,14 @@ object FavoriteAppsRegistry { emptyList() } + private fun decodeIds(json: String): List = + try { + JsonMapper.fromJson>(json) + } catch (e: Exception) { + Log.w("FavoriteAppsRegistry", "Failed to decode pinned favorites", e) + emptyList() + } + private const val TYPE_NOSTR = "nostr" private const val TYPE_URL = "url" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8366f718ef..e20c2976b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.FavoriteWebAppScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen @@ -297,6 +298,7 @@ fun BuildNavigation( composableFromEnd { NsitesScreen(accountViewModel, nav) } composableFromEnd { BrowserScreen(accountViewModel, nav) } composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } + composableFromEndArgs { FavoriteWebAppScreen(it.url, accountViewModel, nav) } composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } 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 14b5ddcd52..e8f1822c89 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 @@ -35,14 +35,19 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -83,15 +88,26 @@ fun AppBottomBar( ) return } + + // User-pinned favorite web apps appear as extra tabs after the built-in items. Only WebUrl + // favorites are pinnable (they embed in-process), so a pinned tab always swaps in place. + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle() + val pinnedFavorites = + remember(favorites, pinnedIds) { + pinnedIds.mapNotNull { id -> favorites.firstOrNull { it.id == id } as? FavoriteApp.WebUrl } + } + val isKeyboardState by keyboardAsState() if (isKeyboardState == KeyboardState.Closed) { - RenderBottomMenu(items, selectedRoute, accountViewModel, onClick) + RenderBottomMenu(items, pinnedFavorites, selectedRoute, accountViewModel, onClick) } } @Composable private fun RenderBottomMenu( items: List, + favoriteTabs: List, selectedRoute: Route?, accountViewModel: AccountViewModel, nav: (Route) -> Unit, @@ -119,10 +135,39 @@ private fun RenderBottomMenu( val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav) } + favoriteTabs.forEach { fav -> + val destination = Route.FavoriteWebApp(fav.url) + FavoriteNavItem(destination == selectedRoute, fav, destination, nav) + } } } } +@Composable +private fun RowScope.FavoriteNavItem( + selected: Boolean, + fav: FavoriteApp.WebUrl, + destination: Route, + nav: (Route) -> Unit, +) { + NavigationBarItem( + alwaysShowLabel = false, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + Icon( + symbol = MaterialSymbols.Public, + contentDescription = fav.label, + modifier = Size25Modifier, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + ) + } + }, + label = { Text(fav.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + selected = selected, + onClick = { nav(destination) }, + ) +} + @Composable private fun RowScope.HasNewItemsIcon( selected: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 50ed4baf71..891bd2d45b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -95,6 +95,10 @@ sealed class Route { @Serializable object FavoriteApps : Route() + @Serializable data class FavoriteWebApp( + val url: String, + ) : Route() + @Serializable object NappletPermissions : Route() @Serializable data class SoftwareAppDetail( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt new file mode 100644 index 0000000000..227f8a60c4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser + +import android.net.Uri +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * A pinned web client rendered as an **in-app tab**: the embedded `:napplet` browser surface fills the + * screen, but the app's bottom bar stays put, so switching to and from it is an ordinary tab swap — no + * new activity, no jarring task switch. The pop-out action hands the same URL to the full-screen + * [BrowserHostActivity] for users who want it as its own window/recents entry. + * + * Only [FavoriteApp.WebUrl][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp.WebUrl] favorites + * reach this screen (they're the only ones pinnable to the bottom bar today); requires API 30+ for the + * cross-process surface. + */ +@Composable +fun FavoriteWebAppScreen( + url: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + EmbeddedFavoriteTab(url, accountViewModel, nav) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.browser_unsupported), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.R) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EmbeddedFavoriteTab( + url: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + + var currentUrl by remember { mutableStateOf(url) } + var canGoBack by remember { mutableStateOf(false) } + + val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null } + var torOn by remember { mutableStateOf(proxyAvailable) } + + val controller = + rememberBrowserController(startUrl = url) { newUrl, back -> + if (newUrl != "about:blank") currentUrl = newUrl + canGoBack = back + } + + BackHandler(enabled = canGoBack) { controller.back() } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = hostLabel(currentUrl), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + actions = { + if (proxyAvailable) { + IconButton(onClick = { + torOn = !torOn + controller.setTor(torOn) + }) { + Icon( + MaterialSymbols.Security, + contentDescription = stringResource(if (torOn) R.string.browser_tor_on else R.string.browser_tor_off), + tint = if (torOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = { controller.reload() }) { + Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload)) + } + IconButton(onClick = { FavoriteAppLauncher.launchUrl(context, url) }) { + Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window)) + } + }, + ) + }, + bottomBar = { + AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) } + }, + ) { padding -> + EmbeddedBrowserSurface( + controller = controller, + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) + } +} + +/** The host of [url] for the tab title, falling back to the raw string. */ +internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url 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 5d0c1a9bbc..8e79a090be 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 @@ -134,6 +134,9 @@ fun FavoriteAppsGrid( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(12.dp), ) { + // Only WebUrl favorites can be pinned as bottom-bar tabs today (they embed in-process). + val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle() + LazyVerticalGrid( columns = GridCells.Adaptive(96.dp), modifier = modifier, @@ -142,8 +145,11 @@ fun FavoriteAppsGrid( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(apps, key = { it.id }) { app -> + val pinnable = app is FavoriteApp.WebUrl FavoriteAppCell( app = app, + isPinned = pinnable && pinnedIds.contains(app.id), + onTogglePin = if (pinnable) ({ FavoriteAppsRegistry.setPinned(app.id, !FavoriteAppsRegistry.isPinned(app.id)) }) else null, onOpen = { onOpen(app) }, onRemove = { onRemove(app) }, ) @@ -155,6 +161,8 @@ fun FavoriteAppsGrid( @Composable private fun FavoriteAppCell( app: FavoriteApp, + isPinned: Boolean, + onTogglePin: (() -> Unit)?, onOpen: () -> Unit, onRemove: () -> Unit, ) { @@ -196,6 +204,16 @@ private fun FavoriteAppCell( ) DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + onTogglePin?.let { toggle -> + DropdownMenuItem( + text = { Text(stringResource(if (isPinned) R.string.favorite_app_unpin else R.string.favorite_app_pin)) }, + leadingIcon = { Icon(if (isPinned) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) }, + onClick = { + menuOpen = false + toggle() + }, + ) + } DropdownMenuItem( text = { Text(stringResource(R.string.favorite_app_remove)) }, leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a0d9328f32..4d0b79f755 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -678,6 +678,8 @@ Add to favorites Remove from favorites Open in its own window + Pin to bottom bar + Unpin from bottom bar That app isn\'t loaded yet. Try again in a moment. Recent nApplet permissions