feat: move favorite to pull-down sheet, remove from OmniBar

Remove the star icon from the browser URL bar (OmniBar) and add a
favorite toggle row to both pull-down sheet surfaces instead:

- Compose TopControlSheet (embedded web tabs and napplets): shows
  filled/outline star with "Add to favorites" / "Remove from favorites"
  sourced from FavoriteAppsRegistry; isFavorite state flows reactively
  through EmbeddedTabChrome so the label updates without reopening.

- Native NappletControlSheet (full-screen NappletBrowserActivity): same
  toggle backed by a new MSG_TOGGLE_WEB_FAVORITE IPC message handled in
  NappletBrokerService; initial state is passed via intent so the star
  opens in the correct filled/outline state for the launch URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBpBE7bQJ2JRni6sYG8UDo
This commit is contained in:
Claude
2026-06-25 01:16:06 +00:00
parent b1aef7d6ac
commit d33d1e36fd
11 changed files with 152 additions and 14 deletions
@@ -77,8 +77,9 @@ object FavoriteAppLauncher {
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && (preferTor || WebUrlNetworkRegistry.useTor(url))
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity.intent(context, url, proxyPort, useTor).apply {
NappletBrowserActivity.intent(context, url, proxyPort, useTor, isFavorite = isFavorite).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
@@ -32,6 +32,7 @@ import android.os.RemoteException
import android.os.SystemClock
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
@@ -41,6 +42,7 @@ 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
@@ -175,6 +177,21 @@ class NappletBrokerService : Service() {
return true
}
// The direct-WebView browser requests a favorite toggle for the current URL (main process only).
if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) {
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 id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebUrl(url, label, System.currentTimeMillis()))
}
return true
}
// The direct-WebView browser relays its per-host Tor choice; persist it (main process only).
if (msg.what == NappletIpc.MSG_SET_WEB_TOR) {
val data = msg.data ?: return true
@@ -195,12 +195,6 @@ private fun BrowserLauncher(
onValueChange = ::onValueChange,
onClear = { field = TextFieldValue("") },
onOpen = { open(field.text) },
onFavorite = {
val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar
FavoriteAppsRegistry.add(
FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()),
)
},
)
},
bottomBar = {
@@ -264,7 +258,6 @@ private fun OmniBar(
onValueChange: (TextFieldValue) -> Unit,
onClear: () -> Unit,
onOpen: () -> Unit,
onFavorite: () -> Unit,
) {
Row(
modifier =
@@ -309,9 +302,6 @@ private fun OmniBar(
),
)
if (field.text.isNotBlank()) {
IconButton(onClick = onFavorite) {
Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add))
}
IconButton(onClick = onOpen) {
Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringResource(R.string.browser_go))
}
@@ -44,9 +44,12 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
@@ -104,6 +107,9 @@ private fun EmbeddedFavoriteTab(
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(url)) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebUrl && it.url == currentUrl } }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val controller =
@@ -121,7 +127,7 @@ private fun EmbeddedFavoriteTab(
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
val chrome =
remember(currentUrl, torOn, proxyAvailable) {
remember(currentUrl, torOn, proxyAvailable, isFavorite) {
EmbeddedTabChrome(
title = hostLabel(currentUrl),
isSandbox = false,
@@ -133,6 +139,15 @@ private fun EmbeddedFavoriteTab(
controller.setTor(torOn)
WebUrlNetworkRegistry.set(url, torOn)
},
isFavorite = isFavorite,
onFavorite = {
val favId = "url:$currentUrl"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebUrl(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
}
},
)
}
// Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a
@@ -37,4 +37,8 @@ data class EmbeddedTabChrome(
val onToggleTor: () -> Unit = {},
/** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */
val onInfo: (() -> Unit)? = null,
/** Whether the current URL/app is already saved as a favorite. */
val isFavorite: Boolean = false,
/** Toggles the current site/app in the favorites registry; null when not applicable. */
val onFavorite: (() -> Unit)? = null,
)
@@ -131,6 +131,15 @@ fun TopControlSheet(
onExpandedChange(false)
chrome.onOpenFull()
}
chrome.onFavorite?.let { toggleFavorite ->
SheetItem(
if (chrome.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder,
stringResource(if (chrome.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add),
) {
onExpandedChange(false)
toggleFavorite()
}
}
onConsole?.let { showConsole ->
SheetItem(
MaterialSymbols.Code,
@@ -53,9 +53,11 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -125,6 +127,9 @@ private fun EmbeddedNappletTab(
var canGoBack by remember { mutableStateOf(false) }
var showAccess by remember { mutableStateOf(false) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
val controller =
remember(id) {
EmbeddedTabFactory.acquireNapplet(context, coordinate, params, backgroundColor)
@@ -138,15 +143,24 @@ private fun EmbeddedNappletTab(
}
}
// Stable per app (title/coordinate don't change), so the tab layer isn't recomposed every frame.
// Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame.
val chrome =
remember(title, coordinate) {
remember(title, coordinate, isFavorite) {
EmbeddedTabChrome(
title = title.ifBlank { coordinate },
isSandbox = true,
onReload = { controller.reload() },
onOpenFull = { FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) },
onInfo = { showAccess = true },
isFavorite = isFavorite,
onFavorite = {
val favId = "nostr:$coordinate"
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
}
},
)
}
// Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect
@@ -590,8 +590,28 @@ class NappletBrowserActivity : ComponentActivity() {
liveUrl = startUrl,
onNavigate = { loadAddress(it) },
onConsole = { consolePanel?.toggle() },
isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) },
).also { controlSheet = it }
private fun sendFavoriteToggle(url: String) {
val host =
runCatching {
android.net.Uri
.parse(url)
.host
}.getOrNull()?.takeIf { it.isNotBlank() } ?: url
val msg =
Message.obtain(null, NappletIpc.MSG_TOGGLE_WEB_FAVORITE).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_FAVORITE_URL, url)
putString(NappletIpc.KEY_FAVORITE_LABEL, host)
}
}
if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg)
}
private fun buildConsolePanel(): View =
NappletConsolePanel(this).also {
it.onClearCallback = { controlSheet?.updateConsoleCount(0) }
@@ -639,6 +659,7 @@ class NappletBrowserActivity : ComponentActivity() {
private const val EXTRA_PROXY_PORT = "proxyPort"
private const val EXTRA_USE_TOR = "useTor"
private const val EXTRA_TITLE = "title"
private const val EXTRA_IS_FAVORITE = "isFavorite"
fun intent(
context: Context,
@@ -646,6 +667,7 @@ class NappletBrowserActivity : ComponentActivity() {
proxyPort: Int,
useTor: Boolean,
title: String = "",
isFavorite: Boolean = false,
): Intent =
Intent()
.setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity")
@@ -653,6 +675,7 @@ class NappletBrowserActivity : ComponentActivity() {
.putExtra(EXTRA_PROXY_PORT, proxyPort)
.putExtra(EXTRA_USE_TOR, useTor)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_IS_FAVORITE, isFavorite)
// Distinct task identity per URL for documentLaunchMode=intoExisting.
.setData(Uri.parse(url))
}
@@ -67,6 +67,9 @@ class NappletControlSheet(
// When non-null, a "Console" row is added to the pull-down sheet. The callback toggles the
// browser's console log panel; the count label is updated via [updateConsoleCount].
private val onConsole: (() -> Unit)? = null,
// When non-null, a favorite toggle row is shown; called with the current URL and new isFavorite state.
isFavoriteInitially: Boolean = false,
private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null,
) : LinearLayout(context) {
private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary)
@@ -75,6 +78,7 @@ class NappletControlSheet(
private var expanded = false
private var torOn = torInitiallyOn
private var currentUrl = liveUrl
private var isFavorite = isFavoriteInitially
private val panel: LinearLayout
private var torLabel: TextView? = null
@@ -82,6 +86,7 @@ class NappletControlSheet(
private var addressField: EditText? = null
private var securityGlyph: TextView? = null
private var consoleLabel: TextView? = null
private var favoriteLabel: TextView? = null
init {
orientation = VERTICAL
@@ -156,6 +161,35 @@ class NappletControlSheet(
},
)
}
onFavoriteToggle?.let {
val label =
TextView(context).apply {
text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add)
setTextColor(onSurface)
textSize = 15f
setPadding(dp(8), 0, 0, 0)
}
favoriteLabel = label
addView(
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(10), dp(8), dp(10))
isClickable = true
setOnClickListener { toggleFavorite() }
addView(
TextView(context).apply {
text = ""
setTextColor(dimmed)
textSize = 18f
width = dp(28)
gravity = Gravity.CENTER
},
)
addView(label)
},
)
}
}
private fun titleRow(): View =
@@ -254,6 +288,19 @@ class NappletControlSheet(
// Don't fight the user while they're editing the field.
addressField?.takeIf { !it.hasFocus() }?.setText(url)
securityGlyph?.text = securityGlyphFor(url)
// Reset favorite state for the new URL (we don't know if it's a favorite without a round-trip).
if (onFavoriteToggle != null) {
isFavorite = false
favoriteLabel?.text = context.getString(R.string.browser_favorite_add)
}
}
private fun toggleFavorite() {
val url = currentUrl?.takeIf { it.isNotBlank() } ?: return
isFavorite = !isFavorite
favoriteLabel?.text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add)
collapse()
onFavoriteToggle?.invoke(url, isFavorite)
}
private fun securityGlyphFor(url: String): String =
@@ -89,6 +89,14 @@ object NappletIpc {
*/
const val MSG_RECORD_ICON = 10
/**
* Host broker (browser mode): toggle a URL in the main-process favorites registry. Carries
* [KEY_FAVORITE_URL] and [KEY_FAVORITE_LABEL]. The broker adds the URL if it isn't already
* a favorite, or removes it if it is identical to the in-app star toggle on the home screen.
* Fire-and-forget; no reply needed.
*/
const val MSG_TOGGLE_WEB_FAVORITE = 11
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
@@ -107,6 +115,12 @@ object NappletIpc {
/** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */
const val KEY_WEB_HOST = "webHost"
/** The full URL (e.g. `https://example.com`) to toggle as a web favorite. */
const val KEY_FAVORITE_URL = "favoriteUrl"
/** A human-readable label for the favorited URL (typically the host). */
const val KEY_FAVORITE_LABEL = "favoriteLabel"
/** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */
const val KEY_FOREGROUND = "foreground"
@@ -34,6 +34,10 @@
<string name="napplet_net_tor_label">Loads over Tor</string>
<string name="napplet_net_open_label">Loads over the open web</string>
<!-- Favorite toggle in the pull-down sheet -->
<string name="browser_favorite_add">Add to favorites</string>
<string name="browser_favorite_remove">Remove from favorites</string>
<!-- Loading / unavailable screens -->
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>