diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 350f8a5259..11c2371792 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt differ 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/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/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..fd2b6a68f3 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,8 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -153,6 +155,26 @@ class NappletBrokerService : Service() { return true } + // The direct-WebView browser relays a successfully loaded page; record it in the visit history + // (main process only). Only clean page-finishes reach here, so misspellings never get recorded. + if (msg.what == NappletIpc.MSG_RECORD_HISTORY) { + val data = msg.data ?: return true + val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true + BrowserHistoryRegistry.init(applicationContext) + BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty()) + return true + } + + // The direct-WebView browser relays a favicon captured from the loaded page; store it by host. + if (msg.what == NappletIpc.MSG_RECORD_ICON) { + val data = msg.data ?: return true + val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true + val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true + BrowserIconRegistry.init(applicationContext) + BrowserIconRegistry.record(host, bytes) + return true + } + // The direct-WebView browser relays its per-host Tor choice; persist it (main process only). if (msg.what == NappletIpc.MSG_SET_WEB_TOR) { val data = msg.data ?: return true 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 82e1aeb03d..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 @@ -20,16 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser -import android.net.Uri import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -43,17 +57,30 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter +import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry +import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -61,15 +88,21 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsGrid +import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems + +/** How many of the most recent history entries the idle browser home surfaces under "Recent". */ +private const val RECENTS_LIMIT = 12 /** * The Browser tab — a **launcher**, not a content surface. The user types a URL here and each opened * site lands in its own full-screen * [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own * task/recents entry), so apps are swapped the normal Android way and a running app never carries an - * editable address bar. Below the omnibox sits the shared [FavoriteAppsGrid] for one-tap access to - * pinned clients. + * editable address bar. + * + * Idle, the body is the [BrowserHome]: pinned favorites on top, then recent visits. As the user types it + * becomes a grouped omnibox suggestion list (favorites first + highlighted, then recents) with inline + * ghost-text completion. Both decorate sites with the favicon captured when they were last opened. * * Requires API 30+ (the keyless `:napplet` browser host needs it); below that the Browser nav item is * hidden, so this screen is unreachable — the fallback message is just defense in depth. @@ -98,23 +131,72 @@ private fun BrowserLauncher( ) { val context = LocalContext.current val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle() + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() - var query by remember { mutableStateOf("") } + var field by remember { mutableStateOf(TextFieldValue("")) } - fun open() { - val url = normalizeUrl(query) ?: return - FavoriteAppLauncher.launchUrl(context, url) + // Favorites + visit history flattened into the neutral candidate shape the ranker consumes. + val candidates = + remember(apps, history) { + buildList { + apps.forEach { if (it is FavoriteApp.WebUrl) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) } + history.forEach { + add( + OmniboxSuggestions.Candidate( + url = it.url, + label = it.title.ifBlank { it.host }, + isFavorite = false, + visitCount = it.visitCount, + lastVisitedAt = it.lastVisitedAt, + ), + ) + } + } + } + + // What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the + // caret when collapsed, or the start of the highlighted suffix when a completion is showing). + val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length)) + val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates) } + + fun open(text: String) { + val target = OmniboxInput.resolve(text) ?: return + FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor) + } + + // Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so + // the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched. + fun onValueChange(new: TextFieldValue) { + val prevTyped = field.text.take(field.selection.min.coerceIn(0, field.text.length)) + val newText = new.text + val appended = + new.selection.collapsed && + new.selection.start == newText.length && + newText.length > prevTyped.length && + newText.startsWith(prevTyped) + if (appended) { + val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates)) + if (completion != null) { + // Keep the user's own casing for the typed prefix; append only the remaining suffix. + val full = newText + completion.substring(newText.length) + field = TextFieldValue(full, TextRange(newText.length, full.length)) + return + } + } + field = new } Scaffold( topBar = { OmniBar( nav = nav, - query = query, - onQueryChange = { query = it }, - onOpen = ::open, + field = field, + onValueChange = ::onValueChange, + onClear = { field = TextFieldValue("") }, + onOpen = { open(field.text) }, onFavorite = { - val url = normalizeUrl(query) ?: return@OmniBar + val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar FavoriteAppsRegistry.add( FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()), ) @@ -125,29 +207,52 @@ private fun BrowserLauncher( AppBottomBar(Route.Browser, nav, accountViewModel) { route -> nav.navBottomBar(route) } }, ) { padding -> - if (apps.isEmpty()) { - Box( - Modifier - .fillMaxSize() - .padding(padding) - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - stringResource(R.string.favorite_apps_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, + val contentModifier = + Modifier + .fillMaxSize() + .padding(padding) + when { + typed.isNotBlank() && suggestions.isNotEmpty() -> + SuggestionGrid( + suggestions = suggestions, + iconKeys = iconKeys, + onOpen = { open(it.url) }, + modifier = contentModifier, + ) + apps.isEmpty() && history.isEmpty() -> + Box( + contentModifier.padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.favorite_apps_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> { + val favoriteUrls = remember(apps) { apps.filterIsInstance().mapTo(HashSet()) { it.url } } + BrowserHome( + apps = apps, + history = history, + iconKeys = iconKeys, + favoriteUrls = favoriteUrls, + onOpenApp = { FavoriteAppLauncher.launch(context, it) }, + onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, + onOpenUrl = { open(it) }, + onToggleRecentFavorite = { entry -> + val id = "url:" + entry.url + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add( + FavoriteApp.WebUrl(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()), + ) + } + }, + onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, + modifier = contentModifier, ) } - } else { - FavoriteAppsGrid( - apps = apps, - onOpen = { FavoriteAppLauncher.launch(context, it) }, - onRemove = { FavoriteAppsRegistry.remove(it.id) }, - modifier = - Modifier - .fillMaxSize() - .padding(padding), - ) } } } @@ -155,8 +260,9 @@ private fun BrowserLauncher( @Composable private fun OmniBar( nav: INav, - query: String, - onQueryChange: (String) -> Unit, + field: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + onClear: () -> Unit, onOpen: () -> Unit, onFavorite: () -> Unit, ) { @@ -176,24 +282,33 @@ private fun OmniBar( IconButton(onClick = nav::popBack) { ArrowBackIcon() } } TextField( - value = query, - onValueChange = onQueryChange, + value = field, + onValueChange = onValueChange, modifier = Modifier.weight(1f), singleLine = true, placeholder = { Text(stringResource(R.string.browser_address_hint)) }, keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, ), keyboardActions = KeyboardActions(onGo = { onOpen() }), + trailingIcon = { + if (field.text.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(MaterialSymbols.Clear, contentDescription = stringResource(R.string.browser_clear)) + } + } + }, colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), ) - if (query.isNotBlank()) { + if (field.text.isNotBlank()) { IconButton(onClick = onFavorite) { Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) } @@ -204,15 +319,208 @@ private fun OmniBar( } } -/** The host of [url] for a favorite's default label, falling back to the raw string. */ -private fun hostOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url - -/** - * Turns raw omnibox text into a loadable URL: trims, rejects blanks, and prepends `https://` when no - * scheme is present (so `example.com` works). Returns null when there's nothing to open. - */ -private fun normalizeUrl(input: String): String? { - val trimmed = input.trim() - if (trimmed.isEmpty()) return null - return if (trimmed.contains("://")) trimmed else "https://$trimmed" +/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */ +@Composable +private fun SuggestionGrid( + suggestions: List, + iconKeys: Set, + onOpen: (OmniboxSuggestions.Suggestion) -> Unit, + modifier: Modifier = Modifier, +) { + val favorites = suggestions.filter { it.isFavorite } + val others = suggestions.filterNot { it.isFavorite } + LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) { + if (favorites.isNotEmpty()) { + item(key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) } + items(favorites, key = { "f:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = true) { onOpen(it) } } + } + if (others.isNotEmpty()) { + item(key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) } + items(others, key = { "o:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = false) { onOpen(it) } } + } + } } + +@Composable +private fun SuggestionRow( + suggestion: OmniboxSuggestions.Suggestion, + iconKeys: Set, + highlighted: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + suggestion.host, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (highlighted) FontWeight.Medium else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (suggestion.label.isNotBlank() && !suggestion.label.equals(suggestion.host, ignoreCase = true)) { + Text( + suggestion.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** The idle body: favorites grid on top, then recent visits — in one grid so they scroll together. */ +@Composable +private fun BrowserHome( + apps: List, + history: List, + iconKeys: Set, + favoriteUrls: Set, + onOpenApp: (FavoriteApp) -> Unit, + onRemoveApp: (FavoriteApp) -> Unit, + onOpenUrl: (String) -> Unit, + onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit, + onRemoveRecent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val recents = remember(history) { history.take(RECENTS_LIMIT) } + LazyVerticalGrid( + columns = GridCells.Adaptive(96.dp), + modifier = modifier, + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (apps.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) } + favoriteAppItems(apps, onOpenApp, onRemoveApp) + } + if (recents.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) } + items(recents, span = { GridItemSpan(maxLineSpan) }, key = { "r:" + it.url }) { entry -> + RecentRow( + entry = entry, + iconKeys = iconKeys, + isFavorited = entry.url in favoriteUrls, + onClick = { onOpenUrl(entry.url) }, + onToggleFavorite = { onToggleRecentFavorite(entry) }, + onRemove = { onRemoveRecent(entry.url) }, + ) + } + } + } +} + +@Composable +private fun RecentRow( + entry: BrowserHistoryEntry, + iconKeys: Set, + isFavorited: Boolean, + onClick: () -> Unit, + onToggleFavorite: () -> Unit, + onRemove: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SiteIcon(entry.host, isFavorite = isFavorited, iconKeys = iconKeys, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.title.ifBlank { entry.host }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.host, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(if (isFavorited) R.string.favorite_app_remove else R.string.favorite_app_add)) }, + leadingIcon = { + Icon(if (isFavorited) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) + }, + onClick = { + menuOpen = false + onToggleFavorite() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_recent_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onRemove() + }, + ) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +/** A site's captured favicon, falling back to a glyph (a star for favorites, the globe otherwise). */ +@Composable +private fun SiteIcon( + host: String, + isFavorite: Boolean, + iconKeys: Set, + modifier: Modifier = Modifier, +) { + val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } + val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public + val tint = MaterialTheme.colorScheme.onSurfaceVariant + if (model == null) { + Icon(symbol, contentDescription = null, modifier = modifier, tint = tint) + } else { + val glyph = rememberMaterialSymbolPainter(symbol, tint) + AsyncImage( + model = model, + contentDescription = null, + modifier = modifier.clip(RoundedCornerShape(6.dp)), + placeholder = glyph, + error = glyph, + fallback = glyph, + ) + } +} + +/** The host of [url] for a favorite's default label, falling back to the raw string. */ +private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url 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..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,6 +37,7 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import org.json.JSONObject @@ -64,6 +65,16 @@ class EmbeddedBrowserController( private var pendingAdapter: SandboxedUiAdapter? = null private var startUrl: String = "about:blank" + private var hasLoadedReal = false + private var blankRecovered = false + + /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ + override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() + private set + + /** Notified on the main thread whenever [loadStatus] changes. */ + override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null + // A single NappletBrowserService instance serves every embedded browser tab, so each controller // stamps its own id on every message; the provider uses it to route controls/updates to this tab. private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" @@ -105,6 +116,7 @@ class EmbeddedBrowserController( pendingAdapter = null onUrlChanged = null onImeEvent = null + onLoadStatusChanged = null } override fun teardown() = unbind() @@ -154,6 +166,12 @@ class EmbeddedBrowserController( val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) } } + NappletBrowserContract.MSG_LOAD_STATE -> { + val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false + val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false + val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() + onLoadState(isLoading, failed, loadedUrl) + } else -> return false } return true @@ -163,6 +181,44 @@ class EmbeddedBrowserController( fun reload() = send(NappletBrowserContract.MSG_RELOAD) {} + /** + * User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from + * scratch. Unlike [reload] (which re-fetches whatever the WebView currently shows — `about:blank` for a + * session that never got its URL), this re-navigates to the favorite's real URL. + */ + override fun retry() { + blankRecovered = false + hasLoadedReal = false + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) + navigate(startUrl) + } + + private fun onLoadState( + isLoading: Boolean, + failed: Boolean, + loadedUrl: String, + ) { + // A favorite whose session settled on about:blank never received its real URL (a warm session built + // before the URL was wired through). Re-navigate once to the canonical URL — reload() can't fix this + // because it would just reload about:blank. Scoped to a real startUrl, so the generic browser's + // intentional about:blank new-tab page is left alone. + if (!isLoading && !failed && loadedUrl.isBlankPage() && !startUrl.isBlankPage() && !blankRecovered) { + blankRecovered = true + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) + navigate(startUrl) + return + } + if (!isLoading && !failed && !loadedUrl.isBlankPage()) hasLoadedReal = true + publishLoadStatus(EmbeddedLoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal)) + } + + private fun publishLoadStatus(status: EmbeddedLoadStatus) { + loadStatus = status + onLoadStatusChanged?.invoke(status) + } + + private fun String.isBlankPage() = isEmpty() || this == "about:blank" + fun back() = send(NappletBrowserContract.MSG_BACK) {} fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } 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..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 @@ -157,7 +157,8 @@ private fun EmbeddedFavoriteTab( AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) } }, ) { padding -> - // Reserve the full content area; the warm surface + its top sheet are drawn over these bounds. + // Reserve the full content area; the warm surface, its top sheet, and the loading/error overlay + // are all drawn over these bounds by the tab layer. Box( Modifier .fillMaxSize() 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/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/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/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97925adaec..b9dcf5fa42 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -672,7 +672,12 @@ 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 + 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 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/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/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..2afe6d8268 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,8 +57,10 @@ import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.util.concurrent.Executor /** @@ -81,6 +85,13 @@ class NappletBrowserActivity : ComponentActivity() { private val contentFrame by lazy { FrameLayout(this) } private var loadingView: View? = null private var resumed = false + private var controlSheet: NappletControlSheet? = null + + // Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/ + // unresolved address never enters history. Reset on each main-frame page start. + private var pendingMainFrameUrl: String? = null + private var mainFrameLoadFailed = false + private var lastIconHost: String? = null // ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ---- private var brokerMessenger: Messenger? = null @@ -267,6 +278,22 @@ class NappletBrowserActivity : ComponentActivity() { } WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = BrowserClient() + wv.webChromeClient = BrowserChromeClient() + } + + /** Captures the page favicon (the WebChromeClient is the only source of it) for the launcher's icons. */ + private inner class BrowserChromeClient : android.webkit.WebChromeClient() { + override fun onReceivedIcon( + view: WebView, + icon: Bitmap?, + ) { + if (icon == null || mainFrameLoadFailed) return + val host = OmniboxInput.hostOf(view.url ?: return) ?: return + // De-dupe: a page can fire this several times — store once per host per visit. + if (host == lastIconHost) return + lastIconHost = host + recordIcon(host, icon) + } } private inner class BrowserClient : WebViewClient() { @@ -283,6 +310,29 @@ class NappletBrowserActivity : ComponentActivity() { return true } + override fun onPageStarted( + view: WebView, + url: String, + favicon: Bitmap?, + ) { + // A fresh main-frame navigation: arm history gating and show the new address. + pendingMainFrameUrl = url + mainFrameLoadFailed = false + // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. + if (OmniboxInput.hostOf(url) != lastIconHost) lastIconHost = null + controlSheet?.updateUrl(url) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + // A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this + // navigation from history. Sub-resource errors are irrelevant to whether the page opened. + if (request.isForMainFrame) mainFrameLoadFailed = true + } + override fun onPageCommitVisible( view: WebView, url: String, @@ -290,18 +340,84 @@ class NappletBrowserActivity : ComponentActivity() { // The page has painted its first frame — drop the loading screen. loadingView?.let { contentFrame.removeView(it) } loadingView = null + controlSheet?.updateUrl(url) } override fun doUpdateVisitedHistory( view: WebView, url: String, isReload: Boolean, - ) = syncBackState() + ) { + syncBackState() + controlSheet?.updateUrl(url) + } override fun onPageFinished( view: WebView, url: String, - ) = syncBackState() + ) { + syncBackState() + controlSheet?.updateUrl(url) + // Record only a clean http(s) main-frame load — never a typed-but-failed address. + if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) { + recordHistory(url, view.title) + } + } + } + + /** Relays a successfully loaded page to the main-process broker for the device-local visit history. */ + private fun recordHistory( + url: String, + title: String?, + ) { + val msg = + Message.obtain(null, NappletIpc.MSG_RECORD_HISTORY).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_HISTORY_URL, url) + putString(NappletIpc.KEY_HISTORY_TITLE, title.orEmpty()) + } + } + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + } + + /** Scales [icon] down and relays it to the broker as the favicon for [host] (PNG bytes over IPC). */ + private fun recordIcon( + host: String, + icon: Bitmap, + ) { + val bytes = + runCatching { + val scaled = + if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) { + Bitmap.createScaledBitmap(icon, ICON_MAX_PX, ICON_MAX_PX, true) + } else { + icon + } + ByteArrayOutputStream().use { out -> + scaled.compress(Bitmap.CompressFormat.PNG, 100, out) + out.toByteArray() + } + }.getOrNull() ?: return + val msg = + Message.obtain(null, NappletIpc.MSG_RECORD_ICON).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_ICON_HOST, host) + putByteArray(NappletIpc.KEY_ICON_BYTES, bytes) + } + } + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + } + + /** Loads a user-typed address from the in-page address bar, forcing Tor for `.onion` when available. */ + private fun loadAddress(text: String) { + val resolved = OmniboxInput.resolve(text) ?: return + if (resolved.forceTor && proxyPort > 0 && !useTor) { + useTor = true + applyWebViewProxy(proxyPort) + } + if (this::webView.isInitialized) webView.loadUrl(resolved.url) } // ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ---- @@ -447,7 +563,9 @@ class NappletBrowserActivity : ComponentActivity() { torInitiallyOn = if (proxyPort > 0) useTor else null, onToggleTor = { setNetworkMode(it) }, onInfo = null, - ) + liveUrl = startUrl, + onNavigate = { loadAddress(it) }, + ).also { controlSheet = it } private fun buildLoadingView(): View = LinearLayout(this).apply { @@ -483,6 +601,9 @@ class NappletBrowserActivity : ComponentActivity() { /** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */ private const val FOREGROUND_HEARTBEAT_MS = 30_000L + /** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */ + private const val ICON_MAX_PX = 96 + private const val EXTRA_URL = "url" private const val EXTRA_PROXY_PORT = "proxyPort" private const val EXTRA_USE_TOR = "useTor" 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 c734533613..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 @@ -44,6 +45,7 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import org.json.JSONObject @@ -82,6 +84,10 @@ class NappletBrowserService : Service() { var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 + // Last main-frame error state, pushed to the client so it can show an error/retry overlay over + // the surface (the embedded surface has no error page of its own). + var loadFailed = false + // Per visited origin: its broker-minted launch token, the requests queued until it arrives, and // the origins a mint is already in flight for — so NIP-07 consent is scoped per site, per tab. val originTokens = mutableMapOf() @@ -278,7 +284,12 @@ class NappletBrowserService : Service() { view: WebView, url: String, favicon: android.graphics.Bitmap?, - ) = pushUrl(tab, view) + ) { + // A new main-frame navigation cleared any prior error. + tab?.loadFailed = false + pushUrl(tab, view) + pushLoadState(tab, view, isLoading = true) + } override fun doUpdateVisitedHistory( view: WebView, @@ -289,7 +300,40 @@ class NappletBrowserService : Service() { override fun onPageFinished( view: WebView, url: String, - ) = pushUrl(tab, view) + ) { + pushUrl(tab, view) + pushLoadState(tab, view, isLoading = false) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + // Only a main-frame failure blanks the page; sub-resource errors (a missing image, a blocked + // tracker) are irrelevant to whether the app opened. + if (!request.isForMainFrame) return + tab?.loadFailed = true + pushLoadState(tab, view, isLoading = false) + } + } + + /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ + private fun pushLoadState( + tab: BrowserTab?, + view: WebView, + isLoading: Boolean, + ) { + val message = + Message.obtain(null, NappletBrowserContract.MSG_LOAD_STATE).apply { + data = + Bundle().apply { + putBoolean(NappletBrowserContract.KEY_IS_LOADING, isLoading) + putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, tab?.loadFailed ?: false) + putString(NappletBrowserContract.KEY_URL, view.url.orEmpty()) + } + } + runCatching { tab?.clientMessenger?.send(message) } } private fun pushUrl( @@ -455,14 +499,8 @@ class NappletBrowserService : Service() { private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() } - /** Address-bar text → URL: keep an explicit scheme, prefix a bare domain, else DuckDuckGo search. */ - private fun normalizeUrl(input: String): String { - val text = input.trim() - if (text.isEmpty()) return "about:blank" - if (text.contains("://")) return text - if (!text.contains(' ') && text.contains('.')) return "https://$text" - return "https://duckduckgo.com/?q=" + Uri.encode(text) - } + /** Address-bar text → URL via the shared [OmniboxInput] rules (bare domain → https, else search). */ + private fun normalizeUrl(input: String): String = OmniboxInput.resolve(input)?.url ?: "about:blank" private companion object { private const val TAG = "NappletBrowserService" 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/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( 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..fd2913e607 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,37 @@ object NappletIpc { */ const val MSG_SET_WEB_TOR = 8 + /** + * Host → broker (browser mode): record a *successfully loaded* page in the device-local visit history + * (main process only). Carries [KEY_HISTORY_URL] (the landed URL) and [KEY_HISTORY_TITLE]. Sent only + * after a clean main-frame page-finish — never for a typed-but-failed address — so misspellings never + * enter history. The `:napplet` process can't touch the main process's store, so it relays it here. + */ + const val MSG_RECORD_HISTORY = 9 + + /** + * Host → broker (browser mode): store the favicon for a visited site. Carries [KEY_ICON_HOST] and + * [KEY_ICON_BYTES] (a small PNG, scaled down by the host before sending). Captured from the WebView + * that loaded the page, so it rides the page's own (Tor-routed) network path; the main process never + * fetches it itself. Bytes stay well under the Binder transaction limit. + */ + const val MSG_RECORD_ICON = 10 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" + /** The landed URL of a successfully loaded browser page, for the visit-history record. */ + const val KEY_HISTORY_URL = "historyUrl" + + /** The page title of a successfully loaded browser page, for the visit-history record. */ + const val KEY_HISTORY_TITLE = "historyTitle" + + /** The host a captured favicon belongs to. */ + const val KEY_ICON_HOST = "iconHost" + + /** The captured favicon as PNG bytes. */ + const val KEY_ICON_BYTES = "iconBytes" + /** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */ const val KEY_WEB_HOST = "webHost" 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