Merge pull request #3350 from vitorpamplona/claude/jolly-mccarthy-jyeu87

Browser: add visit history, omnibox suggestions, and favicon capture
This commit is contained in:
Vitor Pamplona
2026-06-24 14:46:58 -04:00
committed by GitHub
30 changed files with 1707 additions and 83 deletions
@@ -0,0 +1,154 @@
/*
* 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.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
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
private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history")
/**
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
* in the omnibox suggestions.
*/
@Serializable
data class BrowserHistoryEntry(
val url: String,
val title: String,
val host: String,
val lastVisitedAt: Long,
val visitCount: Int,
)
/**
* 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.
*
* 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.
*/
object BrowserHistoryRegistry {
private val KEY = stringPreferencesKey("history")
private const val MAX_ENTRIES = 500
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@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
scope.launch {
val json = ctx.browserHistoryDataStore.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
}
}
/**
* Records a successful visit to [url], moving it to the front. An existing entry for the same URL is
* bumped (visit count +1, title refreshed if non-blank); otherwise a new entry is prepended.
*/
fun record(
url: String,
title: String,
) {
val host = OmniboxInput.hostOf(url) ?: url
val now = System.currentTimeMillis()
update { current ->
val existing = current.firstOrNull { it.url == url }
val entry =
if (existing != null) {
existing.copy(
title = title.ifBlank { existing.title },
host = host,
lastVisitedAt = now,
visitCount = existing.visitCount + 1,
)
} else {
BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1)
}
(listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES)
}
}
fun remove(url: String) = update { current -> current.filterNot { it.url == url } }
fun clear() = update { emptyList() }
private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> =
list
.sortedByDescending { it.lastVisitedAt }
.distinctBy { it.url }
.take(MAX_ENTRIES)
private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) {
val next = transform(_history.value)
if (next == _history.value) return
_history.value = next
persist(encode(next))
}
private fun persist(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.browserHistoryDataStore.edit { it[KEY] = json }
}
}
private fun encode(list: List<BrowserHistoryEntry>): String = JsonMapper.toJson(list)
private fun decode(json: String): List<BrowserHistoryEntry> =
try {
JsonMapper.fromJson<List<BrowserHistoryEntry>>(json)
} catch (e: Exception) {
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
emptyList()
}
}
@@ -0,0 +1,98 @@
/*
* 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.favorites
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
/**
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
* visit. Used to decorate favorite cards and omnibox suggestion rows.
*
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
* `File.exists()` disk checks out of composition).
*/
object BrowserIconRegistry {
private const val DIR = "browser_icons"
private val _keys = MutableStateFlow<Set<String>>(emptySet())
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
@Volatile private var iconDir: File? = null
/** Binds the app context and indexes already-stored icons. Idempotent. */
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
iconDir = dir
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
fun record(
host: String,
bytes: ByteArray,
) {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
try {
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
/**
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
*/
fun iconModelFor(host: String): String? {
val dir = iconDir ?: return null
val key = sanitize(host)
if (key !in _keys.value) return null
return "file://" + File(dir, key + PNG).absolutePath
}
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
// icon file, which is harmless for a decoration.
private fun sanitize(host: String): String =
host
.lowercase()
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
.joinToString("")
.take(120)
private const val PNG = ".png"
}
@@ -66,14 +66,17 @@ object FavoriteAppLauncher {
/**
* Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the
* direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively),
* resolving the proxy port + this site's remembered Tor choice here in the main process.
* resolving the proxy port + this site's remembered Tor choice here in the main process. [preferTor]
* forces Tor (when available) regardless of the remembered choice — used for `.onion`, which only
* resolves over Tor.
*/
fun launchUrl(
context: Context,
url: String,
preferTor: Boolean = false,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && WebUrlNetworkRegistry.useTor(url)
val useTor = proxyPort > 0 && (preferTor || WebUrlNetworkRegistry.useTor(url))
val intent =
NappletBrowserActivity.intent(context, url, proxyPort, useTor).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
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.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
@@ -153,6 +155,26 @@ class NappletBrokerService : Service() {
return true
}
// The direct-WebView browser relays a successfully loaded page; record it in the visit history
// (main process only). Only clean page-finishes reach here, so misspellings never get recorded.
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())
return true
}
// The direct-WebView browser relays a favicon captured from the loaded page; store it by host.
if (msg.what == NappletIpc.MSG_RECORD_ICON) {
val data = msg.data ?: return true
val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true
val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true
BrowserIconRegistry.init(applicationContext)
BrowserIconRegistry.record(host, bytes)
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
@@ -42,9 +42,11 @@ 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.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -109,6 +111,9 @@ private fun RenderBottomMenu(
// Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan.
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
// Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe.
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
@@ -141,7 +146,11 @@ private fun RenderBottomMenu(
is FavoriteApp.WebUrl -> Route.FavoriteWebApp(fav.url)
is FavoriteApp.NostrApp -> Route.FavoriteNostrApp(fav.coordinate)
}
FavoriteNavItem(destination == selectedRoute, fav, destination, nav)
val iconModel =
remember(fav, iconKeys) {
(fav as? FavoriteApp.WebUrl)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
}
FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav)
}
}
}
@@ -153,6 +162,7 @@ private fun RenderBottomMenu(
private fun RowScope.FavoriteNavItem(
selected: Boolean,
fav: FavoriteApp,
iconModel: Any?,
destination: Route,
nav: (Route) -> Unit,
) {
@@ -160,11 +170,13 @@ private fun RowScope.FavoriteNavItem(
alwaysShowLabel = false,
icon = {
Box(Size27Modifier, contentAlignment = Alignment.Center) {
// The app's own icon (nsite/napplet manifest icon) when it has one, else a type glyph.
// A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else
// the grid glyph).
FavoriteAppIcon(
app = fav,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
modifier = Size25Modifier,
iconModel = iconModel,
)
}
},
@@ -20,16 +20,30 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -43,17 +57,30 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
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.commons.icons.symbols.rememberMaterialSymbolPainter
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.ui.navigation.bottombars.AppBottomBar
@@ -61,15 +88,21 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsGrid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems
/** How many of the most recent history entries the idle browser home surfaces under "Recent". */
private const val RECENTS_LIMIT = 12
/**
* The Browser tab — a **launcher**, not a content surface. The user types a URL here and each opened
* site lands in its own full-screen
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
* task/recents entry), so apps are swapped the normal Android way and a running app never carries an
* editable address bar. Below the omnibox sits the shared [FavoriteAppsGrid] for one-tap access to
* pinned clients.
* editable address bar.
*
* Idle, the body is the [BrowserHome]: pinned favorites on top, then recent visits. As the user types it
* becomes a grouped omnibox suggestion list (favorites first + highlighted, then recents) with inline
* ghost-text completion. Both decorate sites with the favicon captured when they were last opened.
*
* Requires API 30+ (the keyless `:napplet` browser host needs it); below that the Browser nav item is
* hidden, so this screen is unreachable — the fallback message is just defense in depth.
@@ -98,23 +131,72 @@ private fun BrowserLauncher(
) {
val context = LocalContext.current
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var field by remember { mutableStateOf(TextFieldValue("")) }
fun open() {
val url = normalizeUrl(query) ?: return
FavoriteAppLauncher.launchUrl(context, url)
// Favorites + visit history flattened into the neutral candidate shape the ranker consumes.
val candidates =
remember(apps, history) {
buildList {
apps.forEach { if (it is FavoriteApp.WebUrl) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) }
history.forEach {
add(
OmniboxSuggestions.Candidate(
url = it.url,
label = it.title.ifBlank { it.host },
isFavorite = false,
visitCount = it.visitCount,
lastVisitedAt = it.lastVisitedAt,
),
)
}
}
}
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates) }
fun open(text: String) {
val target = OmniboxInput.resolve(text) ?: return
FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor)
}
// Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so
// the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched.
fun onValueChange(new: TextFieldValue) {
val prevTyped = field.text.take(field.selection.min.coerceIn(0, field.text.length))
val newText = new.text
val appended =
new.selection.collapsed &&
new.selection.start == newText.length &&
newText.length > prevTyped.length &&
newText.startsWith(prevTyped)
if (appended) {
val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates))
if (completion != null) {
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
val full = newText + completion.substring(newText.length)
field = TextFieldValue(full, TextRange(newText.length, full.length))
return
}
}
field = new
}
Scaffold(
topBar = {
OmniBar(
nav = nav,
query = query,
onQueryChange = { query = it },
onOpen = ::open,
field = field,
onValueChange = ::onValueChange,
onClear = { field = TextFieldValue("") },
onOpen = { open(field.text) },
onFavorite = {
val url = normalizeUrl(query) ?: return@OmniBar
val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar
FavoriteAppsRegistry.add(
FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()),
)
@@ -125,29 +207,52 @@ private fun BrowserLauncher(
AppBottomBar(Route.Browser, nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
if (apps.isEmpty()) {
Box(
Modifier
.fillMaxSize()
.padding(padding)
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.favorite_apps_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
val contentModifier =
Modifier
.fillMaxSize()
.padding(padding)
when {
typed.isNotBlank() && suggestions.isNotEmpty() ->
SuggestionGrid(
suggestions = suggestions,
iconKeys = iconKeys,
onOpen = { open(it.url) },
modifier = contentModifier,
)
apps.isEmpty() && history.isEmpty() ->
Box(
contentModifier.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.favorite_apps_empty),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
val favoriteUrls = remember(apps) { apps.filterIsInstance<FavoriteApp.WebUrl>().mapTo(HashSet()) { it.url } }
BrowserHome(
apps = apps,
history = history,
iconKeys = iconKeys,
favoriteUrls = favoriteUrls,
onOpenApp = { FavoriteAppLauncher.launch(context, it) },
onRemoveApp = { FavoriteAppsRegistry.remove(it.id) },
onOpenUrl = { open(it) },
onToggleRecentFavorite = { entry ->
val id = "url:" + entry.url
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(
FavoriteApp.WebUrl(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()),
)
}
},
onRemoveRecent = { BrowserHistoryRegistry.remove(it) },
modifier = contentModifier,
)
}
} else {
FavoriteAppsGrid(
apps = apps,
onOpen = { FavoriteAppLauncher.launch(context, it) },
onRemove = { FavoriteAppsRegistry.remove(it.id) },
modifier =
Modifier
.fillMaxSize()
.padding(padding),
)
}
}
}
@@ -155,8 +260,9 @@ private fun BrowserLauncher(
@Composable
private fun OmniBar(
nav: INav,
query: String,
onQueryChange: (String) -> Unit,
field: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onClear: () -> Unit,
onOpen: () -> Unit,
onFavorite: () -> Unit,
) {
@@ -176,24 +282,33 @@ private fun OmniBar(
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
}
TextField(
value = query,
onValueChange = onQueryChange,
value = field,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text(stringResource(R.string.browser_address_hint)) },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { onOpen() }),
trailingIcon = {
if (field.text.isNotEmpty()) {
IconButton(onClick = onClear) {
Icon(MaterialSymbols.Clear, contentDescription = stringResource(R.string.browser_clear))
}
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
if (query.isNotBlank()) {
if (field.text.isNotBlank()) {
IconButton(onClick = onFavorite) {
Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add))
}
@@ -204,15 +319,208 @@ private fun OmniBar(
}
}
/** The host of [url] for a favorite's default label, falling back to the raw string. */
private fun hostOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
/**
* Turns raw omnibox text into a loadable URL: trims, rejects blanks, and prepends `https://` when no
* scheme is present (so `example.com` works). Returns null when there's nothing to open.
*/
private fun normalizeUrl(input: String): String? {
val trimmed = input.trim()
if (trimmed.isEmpty()) return null
return if (trimmed.contains("://")) trimmed else "https://$trimmed"
/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */
@Composable
private fun SuggestionGrid(
suggestions: List<OmniboxSuggestions.Suggestion>,
iconKeys: Set<String>,
onOpen: (OmniboxSuggestions.Suggestion) -> Unit,
modifier: Modifier = Modifier,
) {
val favorites = suggestions.filter { it.isFavorite }
val others = suggestions.filterNot { it.isFavorite }
LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) {
if (favorites.isNotEmpty()) {
item(key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) }
items(favorites, key = { "f:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = true) { onOpen(it) } }
}
if (others.isNotEmpty()) {
item(key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) }
items(others, key = { "o:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = false) { onOpen(it) } }
}
}
}
@Composable
private fun SuggestionRow(
suggestion: OmniboxSuggestions.Suggestion,
iconKeys: Set<String>,
highlighted: Boolean,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp))
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
suggestion.host,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (highlighted) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (suggestion.label.isNotBlank() && !suggestion.label.equals(suggestion.host, ignoreCase = true)) {
Text(
suggestion.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The idle body: favorites grid on top, then recent visits — in one grid so they scroll together. */
@Composable
private fun BrowserHome(
apps: List<FavoriteApp>,
history: List<BrowserHistoryEntry>,
iconKeys: Set<String>,
favoriteUrls: Set<String>,
onOpenApp: (FavoriteApp) -> Unit,
onRemoveApp: (FavoriteApp) -> Unit,
onOpenUrl: (String) -> Unit,
onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit,
onRemoveRecent: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val recents = remember(history) { history.take(RECENTS_LIMIT) }
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
modifier = modifier,
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (apps.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }, key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) }
favoriteAppItems(apps, onOpenApp, onRemoveApp)
}
if (recents.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }, key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) }
items(recents, span = { GridItemSpan(maxLineSpan) }, key = { "r:" + it.url }) { entry ->
RecentRow(
entry = entry,
iconKeys = iconKeys,
isFavorited = entry.url in favoriteUrls,
onClick = { onOpenUrl(entry.url) },
onToggleFavorite = { onToggleRecentFavorite(entry) },
onRemove = { onRemoveRecent(entry.url) },
)
}
}
}
}
@Composable
private fun RecentRow(
entry: BrowserHistoryEntry,
iconKeys: Set<String>,
isFavorited: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
onRemove: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SiteIcon(entry.host, isFavorite = isFavorited, iconKeys = iconKeys, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(
entry.title.ifBlank { entry.host },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
entry.host,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringResource(if (isFavorited) R.string.favorite_app_remove else R.string.favorite_app_add)) },
leadingIcon = {
Icon(if (isFavorited) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null)
},
onClick = {
menuOpen = false
onToggleFavorite()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_recent_remove)) },
leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) },
onClick = {
menuOpen = false
onRemove()
},
)
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
/** A site's captured favicon, falling back to a glyph (a star for favorites, the globe otherwise). */
@Composable
private fun SiteIcon(
host: String,
isFavorite: Boolean,
iconKeys: Set<String>,
modifier: Modifier = Modifier,
) {
val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public
val tint = MaterialTheme.colorScheme.onSurfaceVariant
if (model == null) {
Icon(symbol, contentDescription = null, modifier = modifier, tint = tint)
} else {
val glyph = rememberMaterialSymbolPainter(symbol, tint)
AsyncImage(
model = model,
contentDescription = null,
modifier = modifier.clip(RoundedCornerShape(6.dp)),
placeholder = glyph,
error = glyph,
fallback = glyph,
)
}
}
/** The host of [url] for a favorite's default label, falling back to the raw string. */
private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url
@@ -37,6 +37,7 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import org.json.JSONObject
@@ -64,6 +65,16 @@ class EmbeddedBrowserController(
private var pendingAdapter: SandboxedUiAdapter? = null
private var startUrl: String = "about:blank"
private var hasLoadedReal = false
private var blankRecovered = false
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
private set
/** Notified on the main thread whenever [loadStatus] changes. */
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
// A single NappletBrowserService instance serves every embedded browser tab, so each controller
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
@@ -105,6 +116,7 @@ class EmbeddedBrowserController(
pendingAdapter = null
onUrlChanged = null
onImeEvent = null
onLoadStatusChanged = null
}
override fun teardown() = unbind()
@@ -154,6 +166,12 @@ class EmbeddedBrowserController(
val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true
parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) }
}
NappletBrowserContract.MSG_LOAD_STATE -> {
val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false
val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false
val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()
onLoadState(isLoading, failed, loadedUrl)
}
else -> return false
}
return true
@@ -163,6 +181,44 @@ class EmbeddedBrowserController(
fun reload() = send(NappletBrowserContract.MSG_RELOAD) {}
/**
* User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from
* scratch. Unlike [reload] (which re-fetches whatever the WebView currently shows — `about:blank` for a
* session that never got its URL), this re-navigates to the favorite's real URL.
*/
override fun retry() {
blankRecovered = false
hasLoadedReal = false
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
navigate(startUrl)
}
private fun onLoadState(
isLoading: Boolean,
failed: Boolean,
loadedUrl: String,
) {
// A favorite whose session settled on about:blank never received its real URL (a warm session built
// before the URL was wired through). Re-navigate once to the canonical URL — reload() can't fix this
// because it would just reload about:blank. Scoped to a real startUrl, so the generic browser's
// intentional about:blank new-tab page is left alone.
if (!isLoading && !failed && loadedUrl.isBlankPage() && !startUrl.isBlankPage() && !blankRecovered) {
blankRecovered = true
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
navigate(startUrl)
return
}
if (!isLoading && !failed && !loadedUrl.isBlankPage()) hasLoadedReal = true
publishLoadStatus(EmbeddedLoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal))
}
private fun publishLoadStatus(status: EmbeddedLoadStatus) {
loadStatus = status
onLoadStatusChanged?.invoke(status)
}
private fun String.isBlankPage() = isEmpty() || this == "about:blank"
fun back() = send(NappletBrowserContract.MSG_BACK) {}
fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
@@ -157,7 +157,8 @@ private fun EmbeddedFavoriteTab(
AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
// Reserve the full content area; the warm surface + its top sheet are drawn over these bounds.
// Reserve the full content area; the warm surface, its top sheet, and the loading/error overlay
// are all drawn over these bounds by the tab layer.
Box(
Modifier
.fillMaxSize()
@@ -0,0 +1,77 @@
/*
* 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.embed
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
/**
* Cover for an embedded tab's (z-below) surface while it has nothing to show yet: a spinner until the
* page paints, or an error message + Retry when the load failed or stalled. Painted in the app's theme
* background so a slow / blank / failed load isn't a bare black/white void. Shared by the web-app and
* napplet/nsite favorite screens.
*/
@Composable
fun BoxScope.EmbeddedLoadOverlay(
failed: Boolean,
onRetry: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (failed) {
Text(
text = stringResource(R.string.embedded_tab_load_failed),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) {
Text(stringResource(R.string.retry))
}
} else {
CircularProgressIndicator()
}
}
}
@@ -0,0 +1,35 @@
/*
* 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.embed
/**
* Main-frame load state of an embedded tab (browser web app or napplet/nsite), reported by its
* controller from the provider's WebView. Lets the favorite screen draw a loading spinner / error+retry
* overlay over the (z-below) surface instead of leaving a bare black/white void on a slow or failed load.
*
* [hasLoadedReal] flips true once a real page has finished, so re-entering a warm, already-loaded tab
* doesn't flash a spinner over working content.
*/
data class EmbeddedLoadStatus(
val isLoading: Boolean = false,
val failed: Boolean = false,
val hasLoadedReal: Boolean = false,
)
@@ -46,4 +46,18 @@ interface EmbeddedSurfaceController {
/** Permanently close the session (unbind the service); used on eviction. */
fun teardown()
/**
* Current main-frame load state, so [EmbeddedTabLayer] can draw a loading spinner / error+retry
* overlay over this (z-below) surface — the surface itself sits above the nav screens, so the overlay
* can't live in the screen. The default is "already loaded" (no overlay) for any controller that
* doesn't report state.
*/
val loadStatus: EmbeddedLoadStatus get() = EmbeddedLoadStatus(hasLoadedReal = true)
/** Set by [EmbeddedTabLayer] for the active tab; notified on the main thread when [loadStatus] changes. */
var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)?
/** Re-attempt the load from scratch (the overlay's Retry); default no-op. */
fun retry() {}
}
@@ -51,6 +51,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import kotlinx.coroutines.delay
// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width.
private val OFFSCREEN_SHIFT = 10_000.dp
@@ -147,6 +148,47 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
}
}
// Loading / error overlay for the active tab, drawn AFTER the surfaces so it covers the active
// one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay
// can't live in the screen. A spinner until a real page paints, or an error+retry when the load
// failed or stalled, so a slow / blank / failed load isn't a bare black/white void.
val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller
if (activeController != null && bounds.width > 0f && bounds.height > 0f) {
var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) }
var timedOut by remember(activeId) { mutableStateOf(false) }
DisposableEffect(activeId, activeController) {
activeController.onLoadStatusChanged = { loadStatus = it }
onDispose { activeController.onLoadStatusChanged = null }
}
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
LaunchedEffect(activeId, loadStatus) {
timedOut = false
if (!loadStatus.hasLoadedReal && !loadStatus.failed) {
delay(12_000)
timedOut = true
}
}
if (!loadStatus.hasLoadedReal) {
with(density) {
Box(
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp(),
(bounds.top - layerOrigin.y).toDp(),
).size(bounds.width.toDp(), bounds.height.toDp()),
) {
EmbeddedLoadOverlay(
failed = loadStatus.failed || timedOut,
onRetry = {
timedOut = false
activeController.retry()
},
)
}
}
}
}
// The active tab's top pull-down sheet, drawn AFTER the surfaces so it sits on top of the
// (z-below) surface, anchored to the top of the active tab's reserved bounds. Its expanded state
// is owned here (reset per tab) so we can draw a full-area dismiss scrim behind the open sheet —
@@ -38,6 +38,7 @@ import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import org.json.JSONObject
@@ -81,6 +82,15 @@ class EmbeddedNappletController(
/** A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*). */
var onNotice: ((String) -> Unit)? = null
private var hasLoadedReal = false
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
private set
/** Notified on the main thread whenever [loadStatus] changes. */
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
override var onImeEvent: ((ImeEvent) -> Unit)? = null
private val connection =
@@ -115,6 +125,7 @@ class EmbeddedNappletController(
onStateChanged = null
onNotice = null
onImeEvent = null
onLoadStatusChanged = null
}
override fun attachView(view: SandboxedSdkView) {
@@ -167,6 +178,11 @@ class EmbeddedNappletController(
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) }
}
NappletEmbedContract.MSG_LOAD_STATE -> {
val isLoading = msg.data?.getBoolean(NappletEmbedContract.KEY_IS_LOADING, false) ?: false
val failed = msg.data?.getBoolean(NappletEmbedContract.KEY_LOAD_FAILED, false) ?: false
onLoadState(isLoading, failed)
}
else -> return false
}
return true
@@ -201,6 +217,26 @@ class EmbeddedNappletController(
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
/** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */
override fun retry() {
hasLoadedReal = false
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
reload()
}
private fun onLoadState(
isLoading: Boolean,
failed: Boolean,
) {
if (!isLoading && !failed) hasLoadedReal = true
publishLoadStatus(EmbeddedLoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal))
}
private fun publishLoadStatus(status: EmbeddedLoadStatus) {
loadStatus = status
onLoadStatusChanged?.invoke(status)
}
/** Pause/resume the applet's JS when the tab leaves/returns to the foreground (background gating). */
fun pause() {
wantPaused = true
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
@@ -60,10 +61,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -141,25 +144,45 @@ fun FavoriteAppsGrid(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(apps, key = { it.id }) { app ->
FavoriteAppCell(
app = app,
onOpen = { onOpen(app) },
onRemove = { onRemove(app) },
)
}
favoriteAppItems(apps, onOpen, onRemove)
}
}
/**
* Emits the favorite-app cells into any [LazyVerticalGrid] (the Favorite Apps tab, the browser home),
* so callers can mix them with their own headers/sections in a single grid.
*/
fun LazyGridScope.favoriteAppItems(
apps: List<FavoriteApp>,
onOpen: (FavoriteApp) -> Unit,
onRemove: (FavoriteApp) -> Unit,
) {
items(apps, key = { it.id }) { app ->
FavoriteAppCell(
app = app,
onOpen = { onOpen(app) },
onRemove = { onRemove(app) },
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FavoriteAppCell(
internal fun FavoriteAppCell(
app: FavoriteApp,
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
// For a plain web favorite, prefer the favicon captured when its site was opened; nsites/napplets keep
// their manifest icon. Observing the key set recomputes the model as an icon arrives.
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
val faviconModel =
remember(app, iconKeys) {
(app as? FavoriteApp.WebUrl)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
}
Column(
modifier =
Modifier
@@ -183,6 +206,7 @@ private fun FavoriteAppCell(
app = app,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
iconModel = faviconModel,
)
}
Spacer(Modifier.height(6.dp))
@@ -192,7 +192,8 @@ private fun EmbeddedNappletTab(
AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
// Reserve the full content area; the warm surface + its top sheet are drawn over these bounds.
// Reserve the full content area; the warm surface, its top sheet, and the loading/error overlay
// are all drawn over these bounds by the tab layer.
Box(
Modifier
.fillMaxSize()
+5
View File
@@ -672,7 +672,12 @@
<string name="browser_tor_on">Loading over Tor. Tap to use the open web.</string>
<string name="browser_tor_off">Loading over the open web. Tap to use Tor.</string>
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
<string name="embedded_tab_load_failed">Couldn\'t load this app.</string>
<string name="browser_go">Open</string>
<string name="browser_clear">Clear</string>
<string name="browser_favorites">Favorites</string>
<string name="browser_recent_options">Options</string>
<string name="browser_recent_remove">Remove from history</string>
<string name="favorite_apps">Favorite apps</string>
<string name="favorite_apps_empty">No favorite apps yet. Open a web client or nsite and tap the star to pin it here.</string>
<string name="favorite_app_add">Add to favorites</string>
@@ -0,0 +1,114 @@
/*
* 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
/**
* Turns raw omnibox text into something the browser can load. The single source of truth shared by the
* launcher's address bar (main process) and the in-page address bar in the `:napplet` browser host, so
* both behave identically.
*
* The rules, in order:
* - blank → null (nothing to open)
* - already has a scheme (`foo://…`) → used verbatim
* - looks like a host/URL (no spaces, has a dot, or is `localhost`) → `https://` prepended
* - anything else → a search on [searchPrefix] (DuckDuckGo by default), URL-encoded
*
* [Resolved.forceTor] is set for `.onion` addresses, which only resolve over Tor; the caller ORs it with
* the user's per-site choice. Pure and platform-agnostic (no `android.net.Uri`) so it lives in commons
* and is unit-tested directly.
*/
object OmniboxInput {
/** Default search engine. A prefix the query is URL-encoded onto; swappable per call so a future setting can override it. */
const val DEFAULT_SEARCH_PREFIX = "https://duckduckgo.com/?q="
data class Resolved(
val url: String,
val forceTor: Boolean,
)
fun resolve(
raw: String,
searchPrefix: String = DEFAULT_SEARCH_PREFIX,
): Resolved? {
val text = raw.trim()
if (text.isEmpty()) return null
if (text.contains("://")) return Resolved(text, isOnion(text))
if (looksLikeHost(text)) {
val url = "https://$text"
return Resolved(url, isOnion(url))
}
return Resolved(searchPrefix + encodeQuery(text), forceTor = false)
}
/**
* True when [text] (with no scheme) reads as a hostname/URL rather than a search query: no spaces, and
* either `localhost` or a dotted host (so `example.com`, `1.2.3.4`, `localhost:8080` are hosts but
* `how to tie a knot` and `cats` are searches).
*/
fun looksLikeHost(text: String): Boolean {
if (text.isEmpty() || text.any { it.isWhitespace() }) return false
val host = hostPart(text)
if (host.isEmpty()) return false
if (host.equals("localhost", ignoreCase = true)) return true
return host.contains('.') && !host.startsWith('.') && !host.endsWith('.')
}
/** The host of [url] (scheme/userinfo/port/path stripped), or null when it can't be read. */
fun hostOf(url: String): String? {
val authority =
url
.substringAfter("://", url)
.substringBefore('/')
.substringBefore('?')
.substringBefore('#')
val afterUserInfo = authority.substringAfterLast('@', authority)
// IPv6 literal: keep everything inside the brackets.
if (afterUserInfo.startsWith('[')) return afterUserInfo.substringAfter('[').substringBefore(']').ifBlank { null }
val host = afterUserInfo.substringBefore(':')
return host.ifBlank { null }
}
private fun hostPart(text: String): String =
text
.substringBefore('/')
.substringBefore('?')
.substringBefore('#')
.substringBefore(':')
private fun isOnion(url: String): Boolean = hostOf(url)?.endsWith(".onion", ignoreCase = true) == true
private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
private val HEX = "0123456789ABCDEF".toCharArray()
/** Percent-encodes [s] as a URL query component (UTF-8), so a multi-word search survives as a single param. */
fun encodeQuery(s: String): String {
val sb = StringBuilder(s.length)
for (byte in s.encodeToByteArray()) {
val c = byte.toInt() and 0xFF
if (c < 128 && c.toChar() in UNRESERVED) {
sb.append(c.toChar())
} else {
sb.append('%').append(HEX[c shr 4]).append(HEX[c and 0x0F])
}
}
return sb.toString()
}
}
@@ -0,0 +1,113 @@
/*
* 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
/**
* Ranks omnibox autocomplete suggestions out of the user's favorites and visit history, and computes the
* inline ghost-text completion the address bar shows. Pure and platform-agnostic (the caller maps its
* favorites/history into [Candidate]s), so it lives in commons and is unit-tested directly.
*
* Ranking, roughly: a host-prefix match beats a path-prefix match beats a substring match; favorites are
* boosted over plain history; ties break on visit frequency then recency. The result is host-deduplicated
* (one row per site) so a frequently-visited site doesn't flood the list.
*/
object OmniboxSuggestions {
/** A neutral candidate the caller builds from a favorite or a history entry. */
data class Candidate(
val url: String,
val label: String,
val isFavorite: Boolean,
val visitCount: Int = 0,
val lastVisitedAt: Long = 0L,
)
data class Suggestion(
val url: String,
val label: String,
val host: String,
val isFavorite: Boolean,
)
fun rank(
typedRaw: String,
candidates: List<Candidate>,
limit: Int = 8,
): List<Suggestion> {
val typed = typedRaw.trim().lowercase()
if (typed.isEmpty()) return emptyList()
return candidates
.mapNotNull { c ->
val host = OmniboxInput.hostOf(c.url) ?: c.url
val score = score(typed, c, host) ?: return@mapNotNull null
Scored(Suggestion(c.url, c.label, host, c.isFavorite), score, c.lastVisitedAt)
}.sortedWith(compareByDescending<Scored> { it.score }.thenByDescending { it.lastVisitedAt })
.distinctBy { it.suggestion.host.lowercase() }
.take(limit)
.map { it.suggestion }
}
/**
* The host to inline-complete [typedRaw] to (the suffix the address bar pre-selects as ghost text), or
* null when there's nothing to offer. Only completes a bare host fragment (no scheme, no path, no
* spaces) against a ranked host that starts with it — so typing `git` offers `github.com`.
*/
fun completion(
typedRaw: String,
ranked: List<Suggestion>,
): String? {
val typed = typedRaw.trim()
if (typed.isEmpty() || typed.any { it.isWhitespace() }) return null
if (typed.contains("://") || typed.contains('/')) return null
val lower = typed.lowercase()
return ranked
.firstOrNull { it.host.length > typed.length && it.host.lowercase().startsWith(lower) }
?.host
}
private fun score(
typed: String,
c: Candidate,
host: String,
): Double? {
val h = host.lowercase()
val u = c.url.lowercase()
val urlNoScheme = u.substringAfter("://", u)
val l = c.label.lowercase()
var base =
when {
h.startsWith(typed) -> 1000.0
urlNoScheme.startsWith(typed) -> 800.0
h.contains(typed) -> 400.0
l.contains(typed) -> 300.0
u.contains(typed) -> 200.0
else -> return null
}
if (c.isFavorite) base += 500.0
base += minOf(c.visitCount, 50) * 2.0
return base
}
private data class Scored(
val suggestion: Suggestion,
val score: Double,
val lastVisitedAt: Long,
)
}
@@ -32,25 +32,27 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
/**
* The icon for a favorite app: its own manifest icon ([FavoriteApp.iconUrl]) when present, otherwise a
* type glyph (the napplet/nsite grid mark, or the globe for a plain web URL). The glyph also backs the
* remote image as placeholder/error, so a missing or failed icon degrades to it rather than to a blank.
* The icon for a favorite app: [iconModel] (e.g. a captured favicon the host app resolves) when given,
* else its own manifest icon ([FavoriteApp.iconUrl]), else a type glyph (the napplet/nsite grid mark, or
* the globe for a plain web URL). The glyph also backs the remote image as placeholder/error, so a missing
* or failed icon degrades to it rather than to a blank.
*/
@Composable
fun FavoriteAppIcon(
app: FavoriteApp,
tint: Color,
modifier: Modifier = Modifier,
iconModel: Any? = null,
) {
val symbol = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
val url = app.iconUrl
val model = iconModel ?: app.iconUrl?.takeIf { it.isNotBlank() }
if (url.isNullOrBlank()) {
if (model == null) {
Icon(symbol, contentDescription = null, modifier = modifier, tint = tint)
} else {
val glyph = rememberMaterialSymbolPainter(symbol, tint)
AsyncImage(
model = url,
model = model,
contentDescription = null,
modifier = modifier.clip(RoundedCornerShape(6.dp)),
placeholder = glyph,
@@ -0,0 +1,78 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class OmniboxInputTest {
@Test
fun blankIsNull() {
assertNull(OmniboxInput.resolve(""))
assertNull(OmniboxInput.resolve(" "))
}
@Test
fun bareDomainGetsHttps() {
assertEquals(OmniboxInput.Resolved("https://example.com", false), OmniboxInput.resolve("example.com"))
assertEquals(OmniboxInput.Resolved("https://example.com/path?q=1", false), OmniboxInput.resolve(" example.com/path?q=1 "))
}
@Test
fun explicitSchemeIsKept() {
assertEquals(OmniboxInput.Resolved("http://example.com", false), OmniboxInput.resolve("http://example.com"))
assertEquals(OmniboxInput.Resolved("nostr://npub1abc", false), OmniboxInput.resolve("nostr://npub1abc"))
}
@Test
fun localhostAndIpAreHosts() {
assertEquals("https://localhost", OmniboxInput.resolve("localhost")?.url)
assertEquals("https://localhost:8080", OmniboxInput.resolve("localhost:8080")?.url)
assertEquals("https://127.0.0.1", OmniboxInput.resolve("127.0.0.1")?.url)
}
@Test
fun multiWordOrDotlessFallsBackToSearch() {
assertEquals("https://duckduckgo.com/?q=how%20to%20tie%20a%20knot", OmniboxInput.resolve("how to tie a knot")?.url)
assertEquals("https://duckduckgo.com/?q=cats", OmniboxInput.resolve("cats")?.url)
}
@Test
fun searchPrefixIsConfigurable() {
assertEquals("https://search.example/?s=cats", OmniboxInput.resolve("cats", "https://search.example/?s=")?.url)
}
@Test
fun onionForcesTor() {
assertTrue(OmniboxInput.resolve("http://abcd.onion")!!.forceTor)
assertTrue(OmniboxInput.resolve("abcd.onion")!!.forceTor)
assertTrue(!OmniboxInput.resolve("example.com")!!.forceTor)
}
@Test
fun hostOfStripsSchemePortPathUserInfo() {
assertEquals("example.com", OmniboxInput.hostOf("https://example.com/a/b?c=d"))
assertEquals("example.com", OmniboxInput.hostOf("https://user:pw@example.com:8443/x"))
assertEquals("example.com", OmniboxInput.hostOf("example.com"))
}
}
@@ -0,0 +1,87 @@
/*
* 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 com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions.Candidate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class OmniboxSuggestionsTest {
private val candidates =
listOf(
Candidate("https://github.com", "GitHub", isFavorite = true),
Candidate("https://gitlab.com", "GitLab", isFavorite = false, visitCount = 30, lastVisitedAt = 100),
Candidate("https://news.ycombinator.com", "Hacker News", isFavorite = false, visitCount = 5),
Candidate("https://example.com/git", "Example Git Page", isFavorite = false, visitCount = 1),
)
@Test
fun emptyTypedYieldsNothing() {
assertTrue(OmniboxSuggestions.rank("", candidates).isEmpty())
}
@Test
fun hostPrefixRanksAndFavoriteWins() {
val result = OmniboxSuggestions.rank("git", candidates)
// github (favorite, host-prefix) ranks above gitlab (host-prefix), both above example.com (path/substring).
assertEquals(listOf("github.com", "gitlab.com", "example.com"), result.map { it.host })
}
@Test
fun noMatchIsFilteredOut() {
val result = OmniboxSuggestions.rank("github", candidates)
assertEquals(listOf("github.com"), result.map { it.host })
}
@Test
fun dedupesByHost() {
val dupes =
listOf(
Candidate("https://github.com/a", "A", isFavorite = false, visitCount = 1),
Candidate("https://github.com/b", "B", isFavorite = false, visitCount = 9),
)
val result = OmniboxSuggestions.rank("github", dupes)
assertEquals(1, result.size)
assertEquals("https://github.com/b", result.first().url)
}
@Test
fun completionOffersHostForBareFragment() {
val ranked = OmniboxSuggestions.rank("git", candidates)
assertEquals("github.com", OmniboxSuggestions.completion("git", ranked))
}
@Test
fun completionSuppressedForSchemeSlashOrSpaces() {
val ranked = OmniboxSuggestions.rank("git", candidates)
assertNull(OmniboxSuggestions.completion("https://git", ranked))
assertNull(OmniboxSuggestions.completion("github.com/", ranked))
assertNull(OmniboxSuggestions.completion("git hub", ranked))
}
@Test
fun completionNullWhenAlreadyComplete() {
val ranked = OmniboxSuggestions.rank("github.com", candidates)
assertNull(OmniboxSuggestions.completion("github.com", ranked))
}
}
@@ -24,6 +24,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.os.Handler
@@ -34,6 +35,7 @@ import android.os.Messenger
import android.util.Log
import android.view.Gravity
import android.view.View
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
@@ -55,8 +57,10 @@ import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor
/**
@@ -81,6 +85,13 @@ class NappletBrowserActivity : ComponentActivity() {
private val contentFrame by lazy { FrameLayout(this) }
private var loadingView: View? = null
private var resumed = false
private var controlSheet: NappletControlSheet? = null
// Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/
// unresolved address never enters history. Reset on each main-frame page start.
private var pendingMainFrameUrl: String? = null
private var mainFrameLoadFailed = false
private var lastIconHost: String? = null
// ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ----
private var brokerMessenger: Messenger? = null
@@ -267,6 +278,22 @@ class NappletBrowserActivity : ComponentActivity() {
}
WebView.setWebContentsDebuggingEnabled(false)
wv.webViewClient = BrowserClient()
wv.webChromeClient = BrowserChromeClient()
}
/** Captures the page favicon (the WebChromeClient is the only source of it) for the launcher's icons. */
private inner class BrowserChromeClient : android.webkit.WebChromeClient() {
override fun onReceivedIcon(
view: WebView,
icon: Bitmap?,
) {
if (icon == null || mainFrameLoadFailed) return
val host = OmniboxInput.hostOf(view.url ?: return) ?: return
// De-dupe: a page can fire this several times — store once per host per visit.
if (host == lastIconHost) return
lastIconHost = host
recordIcon(host, icon)
}
}
private inner class BrowserClient : WebViewClient() {
@@ -283,6 +310,29 @@ class NappletBrowserActivity : ComponentActivity() {
return true
}
override fun onPageStarted(
view: WebView,
url: String,
favicon: Bitmap?,
) {
// A fresh main-frame navigation: arm history gating and show the new address.
pendingMainFrameUrl = url
mainFrameLoadFailed = false
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
if (OmniboxInput.hostOf(url) != lastIconHost) lastIconHost = null
controlSheet?.updateUrl(url)
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
// A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this
// navigation from history. Sub-resource errors are irrelevant to whether the page opened.
if (request.isForMainFrame) mainFrameLoadFailed = true
}
override fun onPageCommitVisible(
view: WebView,
url: String,
@@ -290,18 +340,84 @@ class NappletBrowserActivity : ComponentActivity() {
// The page has painted its first frame — drop the loading screen.
loadingView?.let { contentFrame.removeView(it) }
loadingView = null
controlSheet?.updateUrl(url)
}
override fun doUpdateVisitedHistory(
view: WebView,
url: String,
isReload: Boolean,
) = syncBackState()
) {
syncBackState()
controlSheet?.updateUrl(url)
}
override fun onPageFinished(
view: WebView,
url: String,
) = syncBackState()
) {
syncBackState()
controlSheet?.updateUrl(url)
// Record only a clean http(s) main-frame load — never a typed-but-failed address.
if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) {
recordHistory(url, view.title)
}
}
}
/** Relays a successfully loaded page to the main-process broker for the device-local visit history. */
private fun recordHistory(
url: String,
title: String?,
) {
val msg =
Message.obtain(null, NappletIpc.MSG_RECORD_HISTORY).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_HISTORY_URL, url)
putString(NappletIpc.KEY_HISTORY_TITLE, title.orEmpty())
}
}
if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg)
}
/** Scales [icon] down and relays it to the broker as the favicon for [host] (PNG bytes over IPC). */
private fun recordIcon(
host: String,
icon: Bitmap,
) {
val bytes =
runCatching {
val scaled =
if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) {
Bitmap.createScaledBitmap(icon, ICON_MAX_PX, ICON_MAX_PX, true)
} else {
icon
}
ByteArrayOutputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
out.toByteArray()
}
}.getOrNull() ?: return
val msg =
Message.obtain(null, NappletIpc.MSG_RECORD_ICON).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_ICON_HOST, host)
putByteArray(NappletIpc.KEY_ICON_BYTES, bytes)
}
}
if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg)
}
/** Loads a user-typed address from the in-page address bar, forcing Tor for `.onion` when available. */
private fun loadAddress(text: String) {
val resolved = OmniboxInput.resolve(text) ?: return
if (resolved.forceTor && proxyPort > 0 && !useTor) {
useTor = true
applyWebViewProxy(proxyPort)
}
if (this::webView.isInitialized) webView.loadUrl(resolved.url)
}
// ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ----
@@ -447,7 +563,9 @@ class NappletBrowserActivity : ComponentActivity() {
torInitiallyOn = if (proxyPort > 0) useTor else null,
onToggleTor = { setNetworkMode(it) },
onInfo = null,
)
liveUrl = startUrl,
onNavigate = { loadAddress(it) },
).also { controlSheet = it }
private fun buildLoadingView(): View =
LinearLayout(this).apply {
@@ -483,6 +601,9 @@ class NappletBrowserActivity : ComponentActivity() {
/** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */
private const val FOREGROUND_HEARTBEAT_MS = 30_000L
/** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */
private const val ICON_MAX_PX = 96
private const val EXTRA_URL = "url"
private const val EXTRA_PROXY_PORT = "proxyPort"
private const val EXTRA_USE_TOR = "useTor"
@@ -63,6 +63,17 @@ object NappletBrowserContract {
/** Client → provider: an IME editing op for the focused field; raw JSON in [KEY_IME_PAYLOAD]. */
const val MSG_IME_OP = 9
/**
* Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a navigation is in
* flight), [KEY_LOAD_FAILED] (the main frame errored), and [KEY_URL] (the page it settled on). Lets
* the main process draw a loading spinner / error overlay over the embedded surface, and recover a
* favorite whose session came up on a blank page (re-navigate to its real URL).
*/
const val MSG_LOAD_STATE = 10
const val KEY_IS_LOADING = "isLoading"
const val KEY_LOAD_FAILED = "loadFailed"
const val KEY_IME_PAYLOAD = "imePayload"
const val KEY_URL = "url"
@@ -34,6 +34,7 @@ import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.util.Log
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
@@ -44,6 +45,7 @@ import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import org.json.JSONObject
@@ -82,6 +84,10 @@ class NappletBrowserService : Service() {
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
// Last main-frame error state, pushed to the client so it can show an error/retry overlay over
// the surface (the embedded surface has no error page of its own).
var loadFailed = false
// Per visited origin: its broker-minted launch token, the requests queued until it arrives, and
// the origins a mint is already in flight for — so NIP-07 consent is scoped per site, per tab.
val originTokens = mutableMapOf<String, String>()
@@ -278,7 +284,12 @@ class NappletBrowserService : Service() {
view: WebView,
url: String,
favicon: android.graphics.Bitmap?,
) = pushUrl(tab, view)
) {
// A new main-frame navigation cleared any prior error.
tab?.loadFailed = false
pushUrl(tab, view)
pushLoadState(tab, view, isLoading = true)
}
override fun doUpdateVisitedHistory(
view: WebView,
@@ -289,7 +300,40 @@ class NappletBrowserService : Service() {
override fun onPageFinished(
view: WebView,
url: String,
) = pushUrl(tab, view)
) {
pushUrl(tab, view)
pushLoadState(tab, view, isLoading = false)
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
// Only a main-frame failure blanks the page; sub-resource errors (a missing image, a blocked
// tracker) are irrelevant to whether the app opened.
if (!request.isForMainFrame) return
tab?.loadFailed = true
pushLoadState(tab, view, isLoading = false)
}
}
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
private fun pushLoadState(
tab: BrowserTab?,
view: WebView,
isLoading: Boolean,
) {
val message =
Message.obtain(null, NappletBrowserContract.MSG_LOAD_STATE).apply {
data =
Bundle().apply {
putBoolean(NappletBrowserContract.KEY_IS_LOADING, isLoading)
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, tab?.loadFailed ?: false)
putString(NappletBrowserContract.KEY_URL, view.url.orEmpty())
}
}
runCatching { tab?.clientMessenger?.send(message) }
}
private fun pushUrl(
@@ -455,14 +499,8 @@ class NappletBrowserService : Service() {
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
/** Address-bar text → URL: keep an explicit scheme, prefix a bare domain, else DuckDuckGo search. */
private fun normalizeUrl(input: String): String {
val text = input.trim()
if (text.isEmpty()) return "about:blank"
if (text.contains("://")) return text
if (!text.contains(' ') && text.contains('.')) return "https://$text"
return "https://duckduckgo.com/?q=" + Uri.encode(text)
}
/** Address-bar text → URL via the shared [OmniboxInput] rules (bare domain → https, else search). */
private fun normalizeUrl(input: String): String = OmniboxInput.resolve(input)?.url ?: "about:blank"
private companion object {
private const val TAG = "NappletBrowserService"
@@ -24,10 +24,13 @@ import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.text.InputType
import android.util.TypedValue
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Switch
@@ -56,6 +59,10 @@ class NappletControlSheet(
// toggling inline — used by the nSite host, where switching routing rebuilds the whole session.
private val onNetworkTap: (() -> Unit)? = null,
private val onInfo: (() -> Unit)? = null,
// The live URL of a plain-website browser. Non-null only for the direct-WebView browser (never an
// nsite/napplet), where it renders an editable address row; [onNavigate] loads what the user types.
liveUrl: String? = null,
private val onNavigate: ((String) -> Unit)? = null,
) : LinearLayout(context) {
private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary)
@@ -63,10 +70,13 @@ class NappletControlSheet(
private var expanded = false
private var torOn = torInitiallyOn
private var currentUrl = liveUrl
private val panel: LinearLayout
private var torLabel: TextView? = null
private var torSwitch: Switch? = null
private var addressField: EditText? = null
private var securityGlyph: TextView? = null
init {
orientation = VERTICAL
@@ -89,6 +99,9 @@ class NappletControlSheet(
setPadding(dp(8), dp(6), dp(8), dp(10))
addView(titleRow())
// Browser only: an editable address bar showing the live URL + a security glyph. nsite/napplet
// hosts pass no navigate callback, so they never get one.
onNavigate?.let { addView(addressRow(currentUrl.orEmpty(), it)) }
addView(divider())
if (torOn != null) addView(torRow())
addView(
@@ -129,6 +142,79 @@ class NappletControlSheet(
)
}
/**
* The browser address bar: a security glyph (🧅 Tor / 🔒 https / 🌐 plain) + an editable URL field.
* Pressing Go hands the trimmed text to [onNavigate] (normalized by the caller) and collapses the sheet.
*/
private fun addressRow(
initial: String,
onNavigate: (String) -> Unit,
): View {
val glyph =
TextView(context).apply {
text = securityGlyphFor(initial)
textSize = 15f
width = dp(28)
gravity = Gravity.CENTER
}
securityGlyph = glyph
val field =
EditText(context).apply {
setText(initial)
setTextColor(onSurface)
setHintTextColor(dimmed)
hint = context.getString(R.string.browser_address_hint)
contentDescription = context.getString(R.string.browser_address_hint)
textSize = 15f
isSingleLine = true
setSelectAllOnFocus(true)
background = null
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
imeOptions = EditorInfo.IME_ACTION_GO
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
val text =
v.text
?.toString()
?.trim()
.orEmpty()
if (text.isNotEmpty()) {
clearFocus()
collapse()
onNavigate(text)
}
true
} else {
false
}
}
}
addressField = field
return LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
addView(glyph)
addView(field)
}
}
/** Refreshes the address bar + security glyph as the page navigates. No-op without an address row. */
fun updateUrl(url: String) {
currentUrl = url
// Don't fight the user while they're editing the field.
addressField?.takeIf { !it.hasFocus() }?.setText(url)
securityGlyph?.text = securityGlyphFor(url)
}
private fun securityGlyphFor(url: String): String =
when {
torOn == true -> "🧅" // 🧅 routed over Tor
url.startsWith("https://", ignoreCase = true) -> "🔒" // 🔒 secure
else -> "🌐" // 🌐 plain http
}
private fun torRow(): View {
// Steady, muted icon (the Switch carries the on/off state) — matches the Compose twin, where the
// lock icon is a constant onSurfaceVariant tint and the Switch is the state indicator.
@@ -181,6 +267,7 @@ class NappletControlSheet(
torOn = next
torSwitch?.isChecked = next
torLabel?.text = context.getString(if (next) R.string.napplet_net_tor_label else R.string.napplet_net_open_label)
securityGlyph?.text = securityGlyphFor(currentUrl.orEmpty())
onToggleTor(next)
}
@@ -77,8 +77,17 @@ object NappletEmbedContract {
/** Client → provider: an IME editing op for the focused field; raw JSON in [KEY_IME_PAYLOAD]. */
const val MSG_IME_OP = 14
/**
* Provider client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a load is in
* flight) and [KEY_LOAD_FAILED] (the main frame errored). Lets the main process draw a loading
* spinner / error+retry overlay over the embedded surface instead of a bare black/white void.
*/
const val MSG_LOAD_STATE = 15
const val KEY_CORE_LIB_INFO = "coreLibInfo"
const val KEY_CAN_GO_BACK = "canGoBack"
const val KEY_IS_LOADING = "isLoading"
const val KEY_LOAD_FAILED = "loadFailed"
const val KEY_NOTICE = "notice"
const val KEY_IME_PAYLOAD = "imePayload"
@@ -35,6 +35,7 @@ import android.os.Message
import android.os.Messenger
import android.util.Log
import android.view.View
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
@@ -99,6 +100,10 @@ class NappletHostService : Service() {
var webView: WebView? = null
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
// Last main-frame error state, pushed to the client so it can show an error/retry overlay over the
// surface (the embedded surface has no error page of its own).
var loadFailed = false
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
}
@@ -311,6 +316,16 @@ class NappletHostService : Service() {
request: WebResourceRequest,
): WebResourceResponse? = tab.contentServer?.serve(request)
override fun onPageStarted(
view: WebView,
url: String,
favicon: android.graphics.Bitmap?,
) {
// A new main-frame navigation cleared any prior error.
tab.loadFailed = false
pushLoadState(tab, isLoading = true)
}
override fun doUpdateVisitedHistory(
view: WebView,
url: String,
@@ -320,7 +335,22 @@ class NappletHostService : Service() {
override fun onPageFinished(
view: WebView,
url: String,
) = pushState(tab, view)
) {
pushState(tab, view)
pushLoadState(tab, isLoading = false)
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
// Only a main-frame failure blanks the applet; a missing sub-resource is irrelevant to whether
// it opened.
if (!request.isForMainFrame) return
tab.loadFailed = true
pushLoadState(tab, isLoading = false)
}
override fun shouldOverrideUrlLoading(
view: WebView,
@@ -346,6 +376,22 @@ class NappletHostService : Service() {
runCatching { tab.clientMessenger?.send(message) }
}
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
private fun pushLoadState(
tab: NappletTab,
isLoading: Boolean,
) {
val message =
Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply {
data =
Bundle().apply {
putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading)
putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed)
}
}
runCatching { tab.clientMessenger?.send(message) }
}
// ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
private fun onShellMessage(
@@ -73,9 +73,37 @@ object NappletIpc {
*/
const val MSG_SET_WEB_TOR = 8
/**
* Host broker (browser mode): record a *successfully loaded* page in the device-local visit history
* (main process only). Carries [KEY_HISTORY_URL] (the landed URL) and [KEY_HISTORY_TITLE]. Sent only
* after a clean main-frame page-finish never for a typed-but-failed address so misspellings never
* enter history. The `:napplet` process can't touch the main process's store, so it relays it here.
*/
const val MSG_RECORD_HISTORY = 9
/**
* Host broker (browser mode): store the favicon for a visited site. Carries [KEY_ICON_HOST] and
* [KEY_ICON_BYTES] (a small PNG, scaled down by the host before sending). Captured from the WebView
* that loaded the page, so it rides the page's own (Tor-routed) network path; the main process never
* fetches it itself. Bytes stay well under the Binder transaction limit.
*/
const val MSG_RECORD_ICON = 10
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
/** The landed URL of a successfully loaded browser page, for the visit-history record. */
const val KEY_HISTORY_URL = "historyUrl"
/** The page title of a successfully loaded browser page, for the visit-history record. */
const val KEY_HISTORY_TITLE = "historyTitle"
/** The host a captured favicon belongs to. */
const val KEY_ICON_HOST = "iconHost"
/** The captured favicon as PNG bytes. */
const val KEY_ICON_BYTES = "iconBytes"
/** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */
const val KEY_WEB_HOST = "webHost"
@@ -11,6 +11,8 @@
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_chrome_reload">Reload</string>
<!-- Browser address bar (direct-WebView browser only) -->
<string name="browser_address_hint">Search or enter address</string>
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>