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 049759b440..703ab00da4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt
@@ -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)
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 fd2b6a68f3..8f2dba934d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
@@ -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
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 ba72b350ee..e4e9d24fa4 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
@@ -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))
}
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
index 52256d3870..4020781557 100644
--- 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
@@ -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
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt
index 753dffb034..42ef553923 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt
@@ -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,
)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt
index 48c48049d3..0ebe85233d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt
index 0cf2d6156c..e93588b49e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt
@@ -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
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
index dc3b8c9e6e..d45442ace0 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
@@ -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))
}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt
index d474152177..78d3d5bec2 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt
@@ -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 =
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt
index fd2913e607..d740e5a03a 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt
@@ -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"
diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml
index 1fef9c9700..cd0e294a13 100644
--- a/nappletHost/src/main/res/values/strings.xml
+++ b/nappletHost/src/main/res/values/strings.xml
@@ -34,6 +34,10 @@
Loads over Tor
Loads over the open web
+
+ Add to favorites
+ Remove from favorites
+
Couldn\'t load “%1$s”
The publisher\'s servers may be offline, or you\'re not connected. You can try again.