From 4e6f6c104bf32be6d7137b9edf26676cbc763d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 14:25:45 +0000 Subject: [PATCH 1/6] fix: improve browser omnibar URL handling and keyboard behavior - Disable autocorrect and autocapitalization in the URL bar so domain names are not mangled by the keyboard's spell-checker - Align normalizeUrl with NappletBrowserService logic: bare domain names (no space, contains dot) get https:// prepended; everything else falls through to a DuckDuckGo search query Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx --- .../ui/screen/loggedIn/browser/BrowserScreen.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 82e1aeb03d..d9cabf691d 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 @@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -183,6 +184,8 @@ private fun OmniBar( placeholder = { Text(stringResource(R.string.browser_address_hint)) }, keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, ), @@ -208,11 +211,14 @@ private fun OmniBar( 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. + * Turns raw omnibox text into a loadable URL: trims, rejects blanks, prepends `https://` for bare + * domain names (e.g. `example.com`), and falls back to a DuckDuckGo search for anything that looks + * like a query rather than a URL. 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" + if (trimmed.contains("://")) return trimmed + if (!trimmed.contains(' ') && trimmed.contains('.')) return "https://$trimmed" + return "https://duckduckgo.com/?q=" + Uri.encode(trimmed) } From 7a03a47d1d494ab9b8b993f02268f502ce5b94d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 15:02:01 +0000 Subject: [PATCH 2/6] feat: omnibox autocomplete, visit history, and in-page address bar for the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the browser URL-bar experience across the launcher and the in-page browser chrome: - Shared URL normalization (commons OmniboxInput): dedupes the logic that was copied between BrowserScreen and NappletBrowserService, recognizes bare domains/localhost/IPs, falls back to a (configurable) DuckDuckGo search, and flags .onion as Tor-only so the launcher forces Tor for it. - Omnibox suggestions (commons OmniboxSuggestions): ranks favorites + visit history by prefix/substring match, favorite boost, and frecency; deduped by host. The launcher body turns into a suggestion list as you type. - Inline ghost-text completion in the address field (TextFieldValue selection), completing a typed host fragment to the top-ranked host. - Visit history (BrowserHistoryRegistry, main process): a device-local, bounded, DataStore-backed store. Pages are recorded ONLY on a clean main-frame load — relayed from the keyless :napplet browser host over a new MSG_RECORD_HISTORY IPC — so misspelled/unresolved addresses never enter it. - In-page editable address bar (websites only) in NappletControlSheet, showing the live URL + a security glyph (Tor/https/plain) and loading what the user types. nsite/napplet hosts pass no navigate callback, so they never get one. Pure logic is covered by unit tests in commons. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx --- .../com/vitorpamplona/amethyst/Amethyst.kt | Bin 7702 -> 7922 bytes .../favorites/BrowserHistoryRegistry.kt | 154 +++++++++++++ .../amethyst/favorites/FavoriteAppLauncher.kt | 7 +- .../amethyst/napplet/NappletBrokerService.kt | 11 + .../screen/loggedIn/browser/BrowserScreen.kt | 218 ++++++++++++++---- amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/commons/browser/OmniboxInput.kt | 114 +++++++++ .../commons/browser/OmniboxSuggestions.kt | 113 +++++++++ .../commons/browser/OmniboxInputTest.kt | 78 +++++++ .../commons/browser/OmniboxSuggestionsTest.kt | 87 +++++++ .../napplethost/NappletBrowserActivity.kt | 75 +++++- .../napplethost/NappletBrowserService.kt | 11 +- .../napplethost/NappletControlSheet.kt | 87 +++++++ .../amethyst/napplethost/NappletIpc.kt | 14 ++ nappletHost/src/main/res/values/strings.xml | 2 + 15 files changed, 910 insertions(+), 62 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestionsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 350f8a525914387c2562cf49041704daff775305..a66287a00e37899c2bc0b7b95cac97307933b1bf 100644 GIT binary patch delta 137 zcmbPc^T~DtA1j|zQGR)GYLQ1~aY=qr|Fi;ETV^KvS!71B~uQ;HQzGEx=tbMrEj@+%aIOViU+i%T-|^NKa~ PxD=oO)wa$11;Tj&WAHHO delta 17 ZcmexlJI!VTAM55o*1O!Bj|*z^0sui#26+Gg diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt new file mode 100644 index 0000000000..c7c92602e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt @@ -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>(emptyList()) + val history: StateFlow> = _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): List = + list + .sortedByDescending { it.lastVisitedAt } + .distinctBy { it.url } + .take(MAX_ENTRIES) + + private inline fun update(transform: (List) -> List) { + 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): String = JsonMapper.toJson(list) + + private fun decode(json: String): List = + try { + JsonMapper.fromJson>(json) + } catch (e: Exception) { + Log.w("BrowserHistoryRegistry", "Failed to decode history", e) + emptyList() + } +} 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 c25081fdcb..049759b440 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -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) 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 b427ee3f03..a3fa7527e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -39,6 +39,7 @@ 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.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -153,6 +154,16 @@ 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 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 d9cabf691d..7ccf13baec 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 @@ -22,12 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser import android.net.Uri import android.os.Build +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.IconButton @@ -46,15 +53,21 @@ import androidx.compose.ui.Modifier 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.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 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.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -69,8 +82,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsGrid * 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. As the user types, the body becomes an omnibox suggestion list (favorites + + * visit history, ranked) with inline ghost-text completion; cleared, it shows the [FavoriteAppsGrid]. * * 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. @@ -99,23 +112,71 @@ private fun BrowserLauncher( ) { val context = LocalContext.current val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val history by BrowserHistoryRegistry.history.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()), ) @@ -126,29 +187,39 @@ 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, + when { + typed.isNotBlank() && suggestions.isNotEmpty() -> + SuggestionList( + suggestions = suggestions, + onOpen = { open(it.url) }, + modifier = + Modifier + .fillMaxSize() + .padding(padding), ) - } - } else { - FavoriteAppsGrid( - apps = apps, - onOpen = { FavoriteAppLauncher.launch(context, it) }, - onRemove = { FavoriteAppsRegistry.remove(it.id) }, - modifier = + apps.isEmpty() -> + Box( Modifier .fillMaxSize() - .padding(padding), - ) + .padding(padding) + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.favorite_apps_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> + FavoriteAppsGrid( + apps = apps, + onOpen = { FavoriteAppLauncher.launch(context, it) }, + onRemove = { FavoriteAppsRegistry.remove(it.id) }, + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) } } } @@ -156,8 +227,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, ) { @@ -177,8 +249,8 @@ 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)) }, @@ -190,13 +262,20 @@ private fun OmniBar( 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)) } @@ -207,18 +286,59 @@ 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, prepends `https://` for bare - * domain names (e.g. `example.com`), and falls back to a DuckDuckGo search for anything that looks - * like a query rather than a URL. Returns null when there's nothing to open. - */ -private fun normalizeUrl(input: String): String? { - val trimmed = input.trim() - if (trimmed.isEmpty()) return null - if (trimmed.contains("://")) return trimmed - if (!trimmed.contains(' ') && trimmed.contains('.')) return "https://$trimmed" - return "https://duckduckgo.com/?q=" + Uri.encode(trimmed) +@Composable +private fun SuggestionList( + suggestions: List, + onOpen: (OmniboxSuggestions.Suggestion) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier) { + items(suggestions, key = { it.url }) { suggestion -> + SuggestionRow(suggestion) { onOpen(suggestion) } + } + } } + +@Composable +private fun SuggestionRow( + suggestion: OmniboxSuggestions.Suggestion, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // A star marks a pinned favorite; otherwise it came from visit history. + Icon( + if (suggestion.isFavorite) MaterialSymbols.Star else MaterialSymbols.History, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + suggestion.host, + style = MaterialTheme.typography.bodyLarge, + 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 host of [url] for a favorite's default label, falling back to the raw string. */ +private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97925adaec..aed68fa195 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -673,6 +673,7 @@ Loading over the open web. Tap to use Tor. The in-app browser needs Android 11 or newer. Open + Clear Favorite apps No favorite apps yet. Open a web client or nsite and tap the star to pin it here. Add to favorites diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt new file mode 100644 index 0000000000..ed555546e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt @@ -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() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestions.kt new file mode 100644 index 0000000000..18107bda8a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestions.kt @@ -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, + limit: Int = 8, + ): List { + 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 { 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, + ): 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, + ) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt new file mode 100644 index 0000000000..a8dc9d0c1c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt @@ -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")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestionsTest.kt new file mode 100644 index 0000000000..d1663540bb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxSuggestionsTest.kt @@ -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)) + } +} 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 5b61ccafed..05117efd29 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -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,6 +57,7 @@ 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.util.concurrent.Executor @@ -81,6 +84,12 @@ 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 // ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ---- private var brokerMessenger: Messenger? = null @@ -283,6 +292,27 @@ 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 + 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 +320,55 @@ 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) + } + + /** 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 +514,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 { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index c734533613..527a3a6b5d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -44,6 +44,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 @@ -455,14 +456,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" 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 5b498687ed..ac05536e61 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt @@ -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) } 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 cf3655d7b7..8ef547e6a3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -73,9 +73,23 @@ 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 + 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 bare host (e.g. `example.com`) a browser Tor choice belongs to. */ const val KEY_WEB_HOST = "webHost" diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 5ad798f4ee..0a5c65cc7a 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -11,6 +11,8 @@ Static site — it has no special access to your account. What this app can access Reload + + Search or enter address “%1$s” published a note as you “%1$s” uploaded a file “%1$s” made a payment From 4f6a21c55d3ff7a5c2062b737c352c1862f39075 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 15:35:40 +0000 Subject: [PATCH 3/6] feat: favorites-first browser home + recents, with captured favicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the omnibox work to modernize the launcher list now that favorites and visit history both exist: - Idle browser home (BrowserHome): pinned favorites on top under a "Favorites" header, then a "Recent" section from the visit history — all in one grid so they scroll together. Long-press a recent to drop it. - Typed suggestions are grouped: a highlighted "Favorites" group first (subtle primary-container tint + medium weight), then "Recent". Favorites still rank first via the existing frecency boost. - Real favicons: captured from the WebView that already loaded the page in the keyless :napplet browser host (so they ride the page's own Tor-routed network path — the main app never fetches host/favicon.ico itself), scaled and relayed as PNG bytes over a new MSG_RECORD_ICON IPC, and stored per-host by BrowserIconRegistry (main process, filesDir). Favorite cards, suggestion rows, and recent rows all show them, falling back to a glyph. FavoriteAppIcon gains an optional iconModel; FavoriteAppCell is reusable via a new LazyGridScope.favoriteAppItems extension so the browser home and the Favorite Apps tab share one cell. Thumbnails deferred to a follow-up. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx --- .../com/vitorpamplona/amethyst/Amethyst.kt | Bin 7922 -> 8130 bytes .../amethyst/favorites/BrowserIconRegistry.kt | 98 ++++++++ .../amethyst/napplet/NappletBrokerService.kt | 11 + .../screen/loggedIn/browser/BrowserScreen.kt | 209 +++++++++++++++--- .../loggedIn/favorites/FavoriteAppsScreen.kt | 40 +++- amethyst/src/main/res/values/strings.xml | 1 + .../commons/favorites/FavoriteAppIcon.kt | 14 +- .../napplethost/NappletBrowserActivity.kt | 52 +++++ .../amethyst/napplethost/NappletIpc.kt | 14 ++ 9 files changed, 390 insertions(+), 49 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index a66287a00e37899c2bc0b7b95cac97307933b1bf..11c2371792c890f7e1981e97e46b308d4117f3b2 100644 GIT binary patch delta 102 zcmexld&qu6AnRm4R(2N8vg-C0X`R6O%iQY#cvQp+-vQ+0FllM{0kk`oI` yN{doc6w(rbqWO8nlLG`rMN?9f^NSKoQj0;t`9+x^miFW_K{>F2n`aA#^8x@Owj+Z8 delta 17 YcmX?P|H*bkAnWFdtXVvpyM@eo0YxJQiU0rr diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt new file mode 100644 index 0000000000..1623d160d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt @@ -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>(emptySet()) + + /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ + val keys: StateFlow> = _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" +} 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 a3fa7527e7..fd2b6a68f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL 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 @@ -164,6 +165,16 @@ class NappletBrokerService : Service() { 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 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 7ccf13baec..b071ff0413 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 @@ -20,11 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser -import android.net.Uri import android.os.Build +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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 @@ -33,8 +37,11 @@ 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.LazyColumn -import androidx.compose.foundation.lazy.items +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.IconButton @@ -50,10 +57,12 @@ 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 @@ -61,13 +70,17 @@ 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 @@ -75,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. As the user types, the body becomes an omnibox suggestion list (favorites + - * visit history, ranked) with inline ghost-text completion; cleared, it shows the [FavoriteAppsGrid]. + * 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. @@ -113,6 +132,7 @@ 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 field by remember { mutableStateOf(TextFieldValue("")) } @@ -187,22 +207,21 @@ private fun BrowserLauncher( AppBottomBar(Route.Browser, nav, accountViewModel) { route -> nav.navBottomBar(route) } }, ) { padding -> + val contentModifier = + Modifier + .fillMaxSize() + .padding(padding) when { typed.isNotBlank() && suggestions.isNotEmpty() -> - SuggestionList( + SuggestionGrid( suggestions = suggestions, + iconKeys = iconKeys, onOpen = { open(it.url) }, - modifier = - Modifier - .fillMaxSize() - .padding(padding), + modifier = contentModifier, ) - apps.isEmpty() -> + apps.isEmpty() && history.isEmpty() -> Box( - Modifier - .fillMaxSize() - .padding(padding) - .padding(32.dp), + contentModifier.padding(32.dp), contentAlignment = Alignment.Center, ) { Text( @@ -211,14 +230,15 @@ private fun BrowserLauncher( ) } else -> - FavoriteAppsGrid( + BrowserHome( apps = apps, - onOpen = { FavoriteAppLauncher.launch(context, it) }, - onRemove = { FavoriteAppsRegistry.remove(it.id) }, - modifier = - Modifier - .fillMaxSize() - .padding(padding), + history = history, + iconKeys = iconKeys, + onOpenApp = { FavoriteAppLauncher.launch(context, it) }, + onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, + onOpenUrl = { open(it) }, + onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, + modifier = contentModifier, ) } } @@ -286,15 +306,24 @@ private fun OmniBar( } } +/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */ @Composable -private fun SuggestionList( +private fun SuggestionGrid( suggestions: List, + iconKeys: Set, onOpen: (OmniboxSuggestions.Suggestion) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn(modifier) { - items(suggestions, key = { it.url }) { suggestion -> - SuggestionRow(suggestion) { onOpen(suggestion) } + 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) } } } } } @@ -302,6 +331,8 @@ private fun SuggestionList( @Composable private fun SuggestionRow( suggestion: OmniboxSuggestions.Suggestion, + iconKeys: Set, + highlighted: Boolean, onClick: () -> Unit, ) { Row( @@ -309,21 +340,17 @@ private fun SuggestionRow( 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, ) { - // A star marks a pinned favorite; otherwise it came from visit history. - Icon( - if (suggestion.isFavorite) MaterialSymbols.Star else MaterialSymbols.History, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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, ) @@ -340,5 +367,117 @@ private fun SuggestionRow( } } +/** The idle body: favorites grid on top, then recent visits — in one grid so they scroll together. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BrowserHome( + apps: List, + history: List, + iconKeys: Set, + onOpenApp: (FavoriteApp) -> Unit, + onRemoveApp: (FavoriteApp) -> Unit, + onOpenUrl: (String) -> 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, + onClick = { onOpenUrl(entry.url) }, + onRemove = { onRemoveRecent(entry.url) }, + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RecentRow( + entry: BrowserHistoryEntry, + iconKeys: Set, + onClick: () -> Unit, + onRemove: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .combinedClickable(onClick = onClick, onLongClick = onRemove) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SiteIcon(entry.host, isFavorite = false, 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, + ) + } + } +} + +@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, + 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) ?: runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url +private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index 24f8498565..f466fa61db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -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, + 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)) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aed68fa195..c098ea201f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -674,6 +674,7 @@ The in-app browser needs Android 11 or newer. Open Clear + Favorites Favorite apps No favorite apps yet. Open a web client or nsite and tap the star to pin it here. Add to favorites diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt index fb7e221211..3769beeb3a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppIcon.kt @@ -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, 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 05117efd29..2afe6d8268 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -60,6 +60,7 @@ 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 /** @@ -90,6 +91,7 @@ class NappletBrowserActivity : ComponentActivity() { // 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 @@ -276,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() { @@ -300,6 +318,8 @@ class NappletBrowserActivity : ComponentActivity() { // 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) } @@ -361,6 +381,35 @@ class NappletBrowserActivity : ComponentActivity() { 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 @@ -552,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" 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 8ef547e6a3..fd2913e607 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -81,6 +81,14 @@ object NappletIpc { */ 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" @@ -90,6 +98,12 @@ object NappletIpc { /** 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" From 2df0c4d6bb8a476d1f089baf106af2ace60c9aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 16:04:46 +0000 Subject: [PATCH 4/6] feat: favicons in bottom nav + 3-dot menu on recent rows - Pinned web favorites in the bottom navigation now show the captured favicon instead of the generic globe (falling back to the globe until one is captured). Resolved via BrowserIconRegistry, same as the launcher cards. - Each Recent row in the browser home gains a 3-dot overflow menu to add the URL to favorites (or remove it if already favorited) and to remove it from history. Replaces the prior long-press-to-remove with a discoverable menu; the row icon shows a star once the site is favorited. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx --- .../ui/navigation/bottombars/AppBottomBar.kt | 16 ++++- .../screen/loggedIn/browser/BrowserScreen.kt | 59 ++++++++++++++++--- amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index bb8f07f1c4..9cc9ffee7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -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, ) } }, 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 b071ff0413..ba72b350ee 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 @@ -21,10 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser import android.os.Build -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -44,6 +42,8 @@ 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 @@ -229,17 +229,30 @@ private fun BrowserLauncher( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - else -> + else -> { + val favoriteUrls = remember(apps) { apps.filterIsInstance().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, ) + } } } } @@ -368,15 +381,16 @@ private fun SuggestionRow( } /** The idle body: favorites grid on top, then recent visits — in one grid so they scroll together. */ -@OptIn(ExperimentalFoundationApi::class) @Composable private fun BrowserHome( apps: List, history: List, iconKeys: Set, + favoriteUrls: Set, onOpenApp: (FavoriteApp) -> Unit, onRemoveApp: (FavoriteApp) -> Unit, onOpenUrl: (String) -> Unit, + onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit, onRemoveRecent: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -398,7 +412,9 @@ private fun BrowserHome( RecentRow( entry = entry, iconKeys = iconKeys, + isFavorited = entry.url in favoriteUrls, onClick = { onOpenUrl(entry.url) }, + onToggleFavorite = { onToggleRecentFavorite(entry) }, onRemove = { onRemoveRecent(entry.url) }, ) } @@ -406,24 +422,26 @@ private fun BrowserHome( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun RecentRow( entry: BrowserHistoryEntry, iconKeys: Set, + isFavorited: Boolean, onClick: () -> Unit, + onToggleFavorite: () -> Unit, onRemove: () -> Unit, ) { + var menuOpen by remember { mutableStateOf(false) } Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .combinedClickable(onClick = onClick, onLongClick = onRemove) - .padding(horizontal = 8.dp, vertical = 10.dp), + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - SiteIcon(entry.host, isFavorite = false, iconKeys = iconKeys, modifier = Modifier.size(24.dp)) + SiteIcon(entry.host, isFavorite = isFavorited, iconKeys = iconKeys, modifier = Modifier.size(24.dp)) Spacer(Modifier.width(16.dp)) Column(Modifier.weight(1f)) { Text( @@ -440,6 +458,31 @@ private fun RecentRow( 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() + }, + ) + } + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c098ea201f..413b463983 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -675,6 +675,8 @@ Open Clear Favorites + Options + Remove from history Favorite apps No favorite apps yet. Open a web client or nsite and tap the star to pin it here. Add to favorites From fb4a2e0858661688196ace237379ea2989375b5d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Jun 2026 14:03:33 -0400 Subject: [PATCH 5/6] fix(browser): recover embedded web-app tab stuck on about:blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A favorite web app pinned to the bottom bar at runtime could come up on a blank surface (black, then white after a manual reload) and never recover. Its warm browser session settled on about:blank — its real URL was dropped on the way in — and the chrome Reload button calls WebView.reload(), which just re-loads about:blank instead of the favorite's page. Fixes: - The provider now reports main-frame load state (start/finish/error) over a new MSG_LOAD_STATE. When a favorite session settles on about:blank while it has a real URL, the controller re-navigates to the canonical URL once. Gated on a real startUrl, so the generic browser's intentional about:blank new-tab page is left alone. Adds controller.retry() (navigate-to-canonical, not reload) for the chrome retry path. - FavoriteWebAppScreen now draws a loading spinner until a real page paints, and an error + Retry overlay when the main frame fails or the load stalls (12s) — so a slow, blank, or failed load is no longer a silent black/white void. Scoped to the browser/WebUrl path; the napplet/nsite path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../browser/EmbeddedBrowserController.kt | 65 ++++++++++++++++ .../loggedIn/browser/FavoriteWebAppScreen.kt | 76 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 1 + .../napplethost/NappletBrowserContract.kt | 11 +++ .../napplethost/NappletBrowserService.kt | 47 +++++++++++- 5 files changed, 196 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt index a8060594aa..3396eafb59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt @@ -42,6 +42,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import org.json.JSONObject import java.util.concurrent.atomic.AtomicLong +/** + * Main-frame load state of an embedded browser session. [hasLoadedReal] flips true once a non-blank page + * has finished, so a screen re-entering a warm, already-loaded tab doesn't flash a spinner. + */ +data class LoadStatus( + val isLoading: Boolean = false, + val failed: Boolean = false, + val hasLoadedReal: Boolean = false, +) + /** * Client-side handle to the embedded browser. Binds [NappletBrowserService] (in the keyless `:napplet` * process), hands its `SandboxedUiAdapter` to a [SandboxedSdkView] so the remote WebView renders inside @@ -64,6 +74,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 a re-entering screen renders the right overlay immediately. */ + var loadStatus: LoadStatus = LoadStatus() + private set + + /** Notified on the main thread whenever [loadStatus] changes. */ + var onLoadStatusChanged: ((LoadStatus) -> 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 +125,7 @@ class EmbeddedBrowserController( pendingAdapter = null onUrlChanged = null onImeEvent = null + onLoadStatusChanged = null } override fun teardown() = unbind() @@ -154,6 +175,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 +190,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. + */ + fun retry() { + blankRecovered = false + hasLoadedReal = false + publishLoadStatus(LoadStatus(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(LoadStatus(isLoading = true)) + navigate(startUrl) + return + } + if (!isLoading && !failed && !loadedUrl.isBlankPage()) hasLoadedReal = true + publishLoadStatus(LoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal)) + } + + private fun publishLoadStatus(status: LoadStatus) { + 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) } 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 48c9f713c0..9e5ef1c2df 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 @@ -24,14 +24,23 @@ import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +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.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,6 +53,8 @@ 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.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher @@ -56,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost +import kotlinx.coroutines.delay /** * A pinned web client rendered as an **in-app tab**. The embedded `:napplet` browser surface is drawn @@ -111,12 +123,29 @@ private fun EmbeddedFavoriteTab( EmbeddedTabFactory.acquireBrowser(context, url, backgroundColor) } - // Keep the URL/back callback fresh (cheap, needs the latest closure). + // Session-scoped load state (read from the warm controller, so re-entering an already-loaded tab + // doesn't flash a spinner over working content). + var status by remember(id) { mutableStateOf(controller.loadStatus) } + var timedOut by remember(id) { mutableStateOf(false) } + + // Keep the URL/back/load callbacks fresh (cheap, needs the latest closures). SideEffect { controller.onUrlChanged = { newUrl, back -> if (newUrl != "about:blank") currentUrl = newUrl canGoBack = back } + controller.onLoadStatusChanged = { status = it } + } + + // Safety net: if no real page has painted and nothing is actively loading after a grace period, treat + // the session as stuck and surface a retry (e.g. a surface that never opened). Restarts on every load + // state change, so it only fires after genuine silence. + LaunchedEffect(id, status) { + timedOut = false + if (!status.hasLoadedReal && !status.failed) { + delay(12_000) + timedOut = true + } } // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. @@ -163,7 +192,50 @@ private fun EmbeddedFavoriteTab( .fillMaxSize() .padding(padding) .onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) }, - ) + ) { + // The embedded WebView surface is drawn (z-below) by the tab layer over these bounds. Until a + // real page paints, cover it with a spinner — or an error/retry when the load failed or stalled + // — so a slow, blank, or failed load isn't a bare black/white void. + if (!status.hasLoadedReal) { + EmbeddedLoadOverlay( + failed = status.failed || timedOut, + onRetry = { + timedOut = false + controller.retry() + }, + ) + } + } + } +} + +@Composable +private fun BoxScope.EmbeddedLoadOverlay( + failed: Boolean, + onRetry: () -> Unit, +) { + Column( + modifier = + Modifier + .matchParentSize() + .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() + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 413b463983..b9dcf5fa42 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -672,6 +672,7 @@ Loading over Tor. Tap to use the open web. Loading over the open web. Tap to use Tor. The in-app browser needs Android 11 or newer. + Couldn\'t load this app. Open Clear Favorites diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index ecffc88973..5323c73edf 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 527a3a6b5d..1ced7fc4ba 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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 @@ -83,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() @@ -279,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, @@ -290,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( From cad987a99e4546710ccff9eda32619b8247f1ced Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Jun 2026 14:35:44 -0400 Subject: [PATCH 6/6] fix(embed): napplet/nsite load overlay + draw it over the surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the web-app load-recovery fix to the napplet/nsite path and fixes a layer bug that kept the overlay from ever showing. The embedded surface (SandboxedSdkView) is drawn by EmbeddedTabLayer, which sits *above* the nav screens in the shell. So a loading/error overlay placed in the favorite screen was covered by the surface's opaque pre-first-frame background — the black void persisted. Move the overlay into EmbeddedTabLayer, drawn over the active tab's bounds (where the chrome sheet already lives), so it actually covers the surface. Also fixes the overlay sizing (fillMaxSize, not matchParentSize, which collapsed to zero inside the reserved Box). - Promote load state to the EmbeddedSurfaceController interface (loadStatus / onLoadStatusChanged / retry), so EmbeddedTabLayer renders one overlay for both the browser and napplet controllers. Shared EmbeddedLoadStatus + EmbeddedLoadOverlay. - NappletHostService now reports main-frame load state (start/finish/error) over a new MSG_LOAD_STATE; EmbeddedNappletController relays it and exposes retry() (= reload the verified content). - The web-app path keeps its about:blank → canonical-URL self-heal; the napplet path has no client-supplied URL to drop, so retry = reload. Verified on device: with the network cut, the brainstorm tab shows "Couldn't load this app." + Retry; restoring the network and tapping Retry loads the page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../browser/EmbeddedBrowserController.kt | 27 +++---- .../loggedIn/browser/FavoriteWebAppScreen.kt | 79 +------------------ .../loggedIn/embed/EmbeddedLoadOverlay.kt | 77 ++++++++++++++++++ .../loggedIn/embed/EmbeddedLoadStatus.kt | 35 ++++++++ .../embed/EmbeddedSurfaceController.kt | 14 ++++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 42 ++++++++++ .../favorites/EmbeddedNappletController.kt | 36 +++++++++ .../favorites/FavoriteNappletScreen.kt | 3 +- .../napplethost/NappletEmbedContract.kt | 9 +++ .../napplethost/NappletHostService.kt | 48 ++++++++++- 10 files changed, 275 insertions(+), 95 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt index 3396eafb59..fd68f6f8d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt @@ -37,21 +37,12 @@ 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 import java.util.concurrent.atomic.AtomicLong -/** - * Main-frame load state of an embedded browser session. [hasLoadedReal] flips true once a non-blank page - * has finished, so a screen re-entering a warm, already-loaded tab doesn't flash a spinner. - */ -data class LoadStatus( - val isLoading: Boolean = false, - val failed: Boolean = false, - val hasLoadedReal: Boolean = false, -) - /** * Client-side handle to the embedded browser. Binds [NappletBrowserService] (in the keyless `:napplet` * process), hands its `SandboxedUiAdapter` to a [SandboxedSdkView] so the remote WebView renders inside @@ -77,12 +68,12 @@ class EmbeddedBrowserController( private var hasLoadedReal = false private var blankRecovered = false - /** Last known main-frame load state, so a re-entering screen renders the right overlay immediately. */ - var loadStatus: LoadStatus = LoadStatus() + /** 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. */ - var onLoadStatusChanged: ((LoadStatus) -> Unit)? = null + 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. @@ -195,10 +186,10 @@ class EmbeddedBrowserController( * 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. */ - fun retry() { + override fun retry() { blankRecovered = false hasLoadedReal = false - publishLoadStatus(LoadStatus(isLoading = true)) + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) navigate(startUrl) } @@ -213,15 +204,15 @@ class EmbeddedBrowserController( // intentional about:blank new-tab page is left alone. if (!isLoading && !failed && loadedUrl.isBlankPage() && !startUrl.isBlankPage() && !blankRecovered) { blankRecovered = true - publishLoadStatus(LoadStatus(isLoading = true)) + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) navigate(startUrl) return } if (!isLoading && !failed && !loadedUrl.isBlankPage()) hasLoadedReal = true - publishLoadStatus(LoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal)) + publishLoadStatus(EmbeddedLoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal)) } - private fun publishLoadStatus(status: LoadStatus) { + private fun publishLoadStatus(status: EmbeddedLoadStatus) { loadStatus = status onLoadStatusChanged?.invoke(status) } 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 9e5ef1c2df..52256d3870 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 @@ -24,23 +24,14 @@ import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -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.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,8 +44,6 @@ 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.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher @@ -67,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost -import kotlinx.coroutines.delay /** * A pinned web client rendered as an **in-app tab**. The embedded `:napplet` browser surface is drawn @@ -123,29 +111,12 @@ private fun EmbeddedFavoriteTab( EmbeddedTabFactory.acquireBrowser(context, url, backgroundColor) } - // Session-scoped load state (read from the warm controller, so re-entering an already-loaded tab - // doesn't flash a spinner over working content). - var status by remember(id) { mutableStateOf(controller.loadStatus) } - var timedOut by remember(id) { mutableStateOf(false) } - - // Keep the URL/back/load callbacks fresh (cheap, needs the latest closures). + // Keep the URL/back callback fresh (cheap, needs the latest closure). SideEffect { controller.onUrlChanged = { newUrl, back -> if (newUrl != "about:blank") currentUrl = newUrl canGoBack = back } - controller.onLoadStatusChanged = { status = it } - } - - // Safety net: if no real page has painted and nothing is actively loading after a grace period, treat - // the session as stuck and surface a retry (e.g. a surface that never opened). Restarts on every load - // state change, so it only fires after genuine silence. - LaunchedEffect(id, status) { - timedOut = false - if (!status.hasLoadedReal && !status.failed) { - delay(12_000) - timedOut = true - } } // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. @@ -186,56 +157,14 @@ 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() .padding(padding) .onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) }, - ) { - // The embedded WebView surface is drawn (z-below) by the tab layer over these bounds. Until a - // real page paints, cover it with a spinner — or an error/retry when the load failed or stalled - // — so a slow, blank, or failed load isn't a bare black/white void. - if (!status.hasLoadedReal) { - EmbeddedLoadOverlay( - failed = status.failed || timedOut, - onRetry = { - timedOut = false - controller.retry() - }, - ) - } - } - } -} - -@Composable -private fun BoxScope.EmbeddedLoadOverlay( - failed: Boolean, - onRetry: () -> Unit, -) { - Column( - modifier = - Modifier - .matchParentSize() - .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() - } + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt new file mode 100644 index 0000000000..11d4cc4e09 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadOverlay.kt @@ -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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt new file mode 100644 index 0000000000..ab42559534 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedLoadStatus.kt @@ -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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt index d9b0557201..1256e50bc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt @@ -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() {} } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index e5861c9075..dd09b87d95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -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) { } } + // 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 — diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt index d0839d6293..507032f81f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt @@ -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 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 f5f7940c50..0cf2d6156c 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 @@ -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() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 7ec7e44000..5ff2037817 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index a368c0b742..5e0f617920 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -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(