mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3350 from vitorpamplona/claude/jolly-mccarthy-jyeu87
Browser: add visit history, omnibox suggestions, and favicon capture
This commit is contained in:
Binary file not shown.
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history")
|
||||
|
||||
/**
|
||||
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
|
||||
* in the omnibox suggestions.
|
||||
*/
|
||||
@Serializable
|
||||
data class BrowserHistoryEntry(
|
||||
val url: String,
|
||||
val title: String,
|
||||
val host: String,
|
||||
val lastVisitedAt: Long,
|
||||
val visitCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites.
|
||||
*
|
||||
* **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host
|
||||
* (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from
|
||||
* the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded
|
||||
* to [MAX_ENTRIES] most-recent entries.
|
||||
*
|
||||
* Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox
|
||||
* never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for
|
||||
* synchronous Compose reads, with write-through persistence to a DataStore on a background scope.
|
||||
*/
|
||||
object BrowserHistoryRegistry {
|
||||
private val KEY = stringPreferencesKey("history")
|
||||
private const val MAX_ENTRIES = 500
|
||||
|
||||
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
|
||||
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
@Volatile private var hydrated = false
|
||||
|
||||
/** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
scope.launch {
|
||||
val json = ctx.browserHistoryDataStore.data.first()[KEY]
|
||||
val loaded = if (json != null) decode(json) else emptyList()
|
||||
// Merge disk under anything already recorded this session (session wins, newest-first).
|
||||
update { current -> dedupeNewestFirst(current + loaded) }
|
||||
hydrated = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful visit to [url], moving it to the front. An existing entry for the same URL is
|
||||
* bumped (visit count +1, title refreshed if non-blank); otherwise a new entry is prepended.
|
||||
*/
|
||||
fun record(
|
||||
url: String,
|
||||
title: String,
|
||||
) {
|
||||
val host = OmniboxInput.hostOf(url) ?: url
|
||||
val now = System.currentTimeMillis()
|
||||
update { current ->
|
||||
val existing = current.firstOrNull { it.url == url }
|
||||
val entry =
|
||||
if (existing != null) {
|
||||
existing.copy(
|
||||
title = title.ifBlank { existing.title },
|
||||
host = host,
|
||||
lastVisitedAt = now,
|
||||
visitCount = existing.visitCount + 1,
|
||||
)
|
||||
} else {
|
||||
BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1)
|
||||
}
|
||||
(listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(url: String) = update { current -> current.filterNot { it.url == url } }
|
||||
|
||||
fun clear() = update { emptyList() }
|
||||
|
||||
private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> =
|
||||
list
|
||||
.sortedByDescending { it.lastVisitedAt }
|
||||
.distinctBy { it.url }
|
||||
.take(MAX_ENTRIES)
|
||||
|
||||
private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) {
|
||||
val next = transform(_history.value)
|
||||
if (next == _history.value) return
|
||||
_history.value = next
|
||||
persist(encode(next))
|
||||
}
|
||||
|
||||
private fun persist(json: String) {
|
||||
val ctx = appContext ?: return
|
||||
scope.launch {
|
||||
ctx.browserHistoryDataStore.edit { it[KEY] = json }
|
||||
}
|
||||
}
|
||||
|
||||
private fun encode(list: List<BrowserHistoryEntry>): String = JsonMapper.toJson(list)
|
||||
|
||||
private fun decode(json: String): List<BrowserHistoryEntry> =
|
||||
try {
|
||||
JsonMapper.fromJson<List<BrowserHistoryEntry>>(json)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
|
||||
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
|
||||
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
|
||||
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
|
||||
* visit. Used to decorate favorite cards and omnibox suggestion rows.
|
||||
*
|
||||
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
|
||||
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
|
||||
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
|
||||
* `File.exists()` disk checks out of composition).
|
||||
*/
|
||||
object BrowserIconRegistry {
|
||||
private const val DIR = "browser_icons"
|
||||
|
||||
private val _keys = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
|
||||
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
|
||||
|
||||
@Volatile private var iconDir: File? = null
|
||||
|
||||
/** Binds the app context and indexes already-stored icons. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (iconDir != null) return
|
||||
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
|
||||
iconDir = dir
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
|
||||
fun record(
|
||||
host: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
val dir = iconDir ?: return
|
||||
if (host.isBlank() || bytes.isEmpty()) return
|
||||
val key = sanitize(host)
|
||||
try {
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
|
||||
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
|
||||
*/
|
||||
fun iconModelFor(host: String): String? {
|
||||
val dir = iconDir ?: return null
|
||||
val key = sanitize(host)
|
||||
if (key !in _keys.value) return null
|
||||
return "file://" + File(dir, key + PNG).absolutePath
|
||||
}
|
||||
|
||||
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
|
||||
// icon file, which is harmless for a decoration.
|
||||
private fun sanitize(host: String): String =
|
||||
host
|
||||
.lowercase()
|
||||
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
|
||||
.joinToString("")
|
||||
.take(120)
|
||||
|
||||
private const val PNG = ".png"
|
||||
}
|
||||
@@ -66,14 +66,17 @@ object FavoriteAppLauncher {
|
||||
/**
|
||||
* Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the
|
||||
* direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively),
|
||||
* resolving the proxy port + this site's remembered Tor choice here in the main process.
|
||||
* resolving the proxy port + this site's remembered Tor choice here in the main process. [preferTor]
|
||||
* forces Tor (when available) regardless of the remembered choice — used for `.onion`, which only
|
||||
* resolves over Tor.
|
||||
*/
|
||||
fun launchUrl(
|
||||
context: Context,
|
||||
url: String,
|
||||
preferTor: Boolean = false,
|
||||
) {
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
val useTor = proxyPort > 0 && WebUrlNetworkRegistry.useTor(url)
|
||||
val useTor = proxyPort > 0 && (preferTor || WebUrlNetworkRegistry.useTor(url))
|
||||
val intent =
|
||||
NappletBrowserActivity.intent(context, url, proxyPort, useTor).apply {
|
||||
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
@@ -153,6 +155,26 @@ class NappletBrokerService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
// The direct-WebView browser relays a successfully loaded page; record it in the visit history
|
||||
// (main process only). Only clean page-finishes reach here, so misspellings never get recorded.
|
||||
if (msg.what == NappletIpc.MSG_RECORD_HISTORY) {
|
||||
val data = msg.data ?: return true
|
||||
val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true
|
||||
BrowserHistoryRegistry.init(applicationContext)
|
||||
BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
// The direct-WebView browser relays a favicon captured from the loaded page; store it by host.
|
||||
if (msg.what == NappletIpc.MSG_RECORD_ICON) {
|
||||
val data = msg.data ?: return true
|
||||
val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true
|
||||
BrowserIconRegistry.init(applicationContext)
|
||||
BrowserIconRegistry.record(host, bytes)
|
||||
return true
|
||||
}
|
||||
|
||||
// The direct-WebView browser relays its per-host Tor choice; persist it (main process only).
|
||||
if (msg.what == NappletIpc.MSG_SET_WEB_TOR) {
|
||||
val data = msg.data ?: return true
|
||||
|
||||
+14
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+357
-49
@@ -20,16 +20,30 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -43,17 +57,30 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
@@ -61,15 +88,21 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsGrid
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems
|
||||
|
||||
/** How many of the most recent history entries the idle browser home surfaces under "Recent". */
|
||||
private const val RECENTS_LIMIT = 12
|
||||
|
||||
/**
|
||||
* The Browser tab — a **launcher**, not a content surface. The user types a URL here and each opened
|
||||
* site lands in its own full-screen
|
||||
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
|
||||
* task/recents entry), so apps are swapped the normal Android way and a running app never carries an
|
||||
* editable address bar. Below the omnibox sits the shared [FavoriteAppsGrid] for one-tap access to
|
||||
* pinned clients.
|
||||
* editable address bar.
|
||||
*
|
||||
* Idle, the body is the [BrowserHome]: pinned favorites on top, then recent visits. As the user types it
|
||||
* becomes a grouped omnibox suggestion list (favorites first + highlighted, then recents) with inline
|
||||
* ghost-text completion. Both decorate sites with the favicon captured when they were last opened.
|
||||
*
|
||||
* Requires API 30+ (the keyless `:napplet` browser host needs it); below that the Browser nav item is
|
||||
* hidden, so this screen is unreachable — the fallback message is just defense in depth.
|
||||
@@ -98,23 +131,72 @@ private fun BrowserLauncher(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var field by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
fun open() {
|
||||
val url = normalizeUrl(query) ?: return
|
||||
FavoriteAppLauncher.launchUrl(context, url)
|
||||
// Favorites + visit history flattened into the neutral candidate shape the ranker consumes.
|
||||
val candidates =
|
||||
remember(apps, history) {
|
||||
buildList {
|
||||
apps.forEach { if (it is FavoriteApp.WebUrl) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) }
|
||||
history.forEach {
|
||||
add(
|
||||
OmniboxSuggestions.Candidate(
|
||||
url = it.url,
|
||||
label = it.title.ifBlank { it.host },
|
||||
isFavorite = false,
|
||||
visitCount = it.visitCount,
|
||||
lastVisitedAt = it.lastVisitedAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
|
||||
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
|
||||
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
|
||||
val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates) }
|
||||
|
||||
fun open(text: String) {
|
||||
val target = OmniboxInput.resolve(text) ?: return
|
||||
FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor)
|
||||
}
|
||||
|
||||
// Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so
|
||||
// the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched.
|
||||
fun onValueChange(new: TextFieldValue) {
|
||||
val prevTyped = field.text.take(field.selection.min.coerceIn(0, field.text.length))
|
||||
val newText = new.text
|
||||
val appended =
|
||||
new.selection.collapsed &&
|
||||
new.selection.start == newText.length &&
|
||||
newText.length > prevTyped.length &&
|
||||
newText.startsWith(prevTyped)
|
||||
if (appended) {
|
||||
val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates))
|
||||
if (completion != null) {
|
||||
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
|
||||
val full = newText + completion.substring(newText.length)
|
||||
field = TextFieldValue(full, TextRange(newText.length, full.length))
|
||||
return
|
||||
}
|
||||
}
|
||||
field = new
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
OmniBar(
|
||||
nav = nav,
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
onOpen = ::open,
|
||||
field = field,
|
||||
onValueChange = ::onValueChange,
|
||||
onClear = { field = TextFieldValue("") },
|
||||
onOpen = { open(field.text) },
|
||||
onFavorite = {
|
||||
val url = normalizeUrl(query) ?: return@OmniBar
|
||||
val url = OmniboxInput.resolve(field.text)?.url ?: return@OmniBar
|
||||
FavoriteAppsRegistry.add(
|
||||
FavoriteApp.WebUrl(url = url, label = hostOf(url), addedAt = System.currentTimeMillis()),
|
||||
)
|
||||
@@ -125,29 +207,52 @@ private fun BrowserLauncher(
|
||||
AppBottomBar(Route.Browser, nav, accountViewModel) { route -> nav.navBottomBar(route) }
|
||||
},
|
||||
) { padding ->
|
||||
if (apps.isEmpty()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.favorite_apps_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
val contentModifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
when {
|
||||
typed.isNotBlank() && suggestions.isNotEmpty() ->
|
||||
SuggestionGrid(
|
||||
suggestions = suggestions,
|
||||
iconKeys = iconKeys,
|
||||
onOpen = { open(it.url) },
|
||||
modifier = contentModifier,
|
||||
)
|
||||
apps.isEmpty() && history.isEmpty() ->
|
||||
Box(
|
||||
contentModifier.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.favorite_apps_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val favoriteUrls = remember(apps) { apps.filterIsInstance<FavoriteApp.WebUrl>().mapTo(HashSet()) { it.url } }
|
||||
BrowserHome(
|
||||
apps = apps,
|
||||
history = history,
|
||||
iconKeys = iconKeys,
|
||||
favoriteUrls = favoriteUrls,
|
||||
onOpenApp = { FavoriteAppLauncher.launch(context, it) },
|
||||
onRemoveApp = { FavoriteAppsRegistry.remove(it.id) },
|
||||
onOpenUrl = { open(it) },
|
||||
onToggleRecentFavorite = { entry ->
|
||||
val id = "url:" + entry.url
|
||||
if (FavoriteAppsRegistry.isFavorite(id)) {
|
||||
FavoriteAppsRegistry.remove(id)
|
||||
} else {
|
||||
FavoriteAppsRegistry.add(
|
||||
FavoriteApp.WebUrl(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()),
|
||||
)
|
||||
}
|
||||
},
|
||||
onRemoveRecent = { BrowserHistoryRegistry.remove(it) },
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FavoriteAppsGrid(
|
||||
apps = apps,
|
||||
onOpen = { FavoriteAppLauncher.launch(context, it) },
|
||||
onRemove = { FavoriteAppsRegistry.remove(it.id) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,8 +260,9 @@ private fun BrowserLauncher(
|
||||
@Composable
|
||||
private fun OmniBar(
|
||||
nav: INav,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
field: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
onFavorite: () -> Unit,
|
||||
) {
|
||||
@@ -176,24 +282,33 @@ private fun OmniBar(
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
}
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
value = field,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.browser_address_hint)) },
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { onOpen() }),
|
||||
trailingIcon = {
|
||||
if (field.text.isNotEmpty()) {
|
||||
IconButton(onClick = onClear) {
|
||||
Icon(MaterialSymbols.Clear, contentDescription = stringResource(R.string.browser_clear))
|
||||
}
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
if (query.isNotBlank()) {
|
||||
if (field.text.isNotBlank()) {
|
||||
IconButton(onClick = onFavorite) {
|
||||
Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add))
|
||||
}
|
||||
@@ -204,15 +319,208 @@ private fun OmniBar(
|
||||
}
|
||||
}
|
||||
|
||||
/** The host of [url] for a favorite's default label, falling back to the raw string. */
|
||||
private fun hostOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
|
||||
|
||||
/**
|
||||
* Turns raw omnibox text into a loadable URL: trims, rejects blanks, and prepends `https://` when no
|
||||
* scheme is present (so `example.com` works). Returns null when there's nothing to open.
|
||||
*/
|
||||
private fun normalizeUrl(input: String): String? {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return if (trimmed.contains("://")) trimmed else "https://$trimmed"
|
||||
/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */
|
||||
@Composable
|
||||
private fun SuggestionGrid(
|
||||
suggestions: List<OmniboxSuggestions.Suggestion>,
|
||||
iconKeys: Set<String>,
|
||||
onOpen: (OmniboxSuggestions.Suggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val favorites = suggestions.filter { it.isFavorite }
|
||||
val others = suggestions.filterNot { it.isFavorite }
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) {
|
||||
if (favorites.isNotEmpty()) {
|
||||
item(key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) }
|
||||
items(favorites, key = { "f:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = true) { onOpen(it) } }
|
||||
}
|
||||
if (others.isNotEmpty()) {
|
||||
item(key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) }
|
||||
items(others, key = { "o:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = false) { onOpen(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestionRow(
|
||||
suggestion: OmniboxSuggestions.Suggestion,
|
||||
iconKeys: Set<String>,
|
||||
highlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
suggestion.host,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (highlighted) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (suggestion.label.isNotBlank() && !suggestion.label.equals(suggestion.host, ignoreCase = true)) {
|
||||
Text(
|
||||
suggestion.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The idle body: favorites grid on top, then recent visits — in one grid so they scroll together. */
|
||||
@Composable
|
||||
private fun BrowserHome(
|
||||
apps: List<FavoriteApp>,
|
||||
history: List<BrowserHistoryEntry>,
|
||||
iconKeys: Set<String>,
|
||||
favoriteUrls: Set<String>,
|
||||
onOpenApp: (FavoriteApp) -> Unit,
|
||||
onRemoveApp: (FavoriteApp) -> Unit,
|
||||
onOpenUrl: (String) -> Unit,
|
||||
onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit,
|
||||
onRemoveRecent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val recents = remember(history) { history.take(RECENTS_LIMIT) }
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(96.dp),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (apps.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }, key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) }
|
||||
favoriteAppItems(apps, onOpenApp, onRemoveApp)
|
||||
}
|
||||
if (recents.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }, key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) }
|
||||
items(recents, span = { GridItemSpan(maxLineSpan) }, key = { "r:" + it.url }) { entry ->
|
||||
RecentRow(
|
||||
entry = entry,
|
||||
iconKeys = iconKeys,
|
||||
isFavorited = entry.url in favoriteUrls,
|
||||
onClick = { onOpenUrl(entry.url) },
|
||||
onToggleFavorite = { onToggleRecentFavorite(entry) },
|
||||
onRemove = { onRemoveRecent(entry.url) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentRow(
|
||||
entry: BrowserHistoryEntry,
|
||||
iconKeys: Set<String>,
|
||||
isFavorited: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SiteIcon(entry.host, isFavorite = isFavorited, iconKeys = iconKeys, modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
entry.title.ifBlank { entry.host },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
entry.host,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options))
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(if (isFavorited) R.string.favorite_app_remove else R.string.favorite_app_add)) },
|
||||
leadingIcon = {
|
||||
Icon(if (isFavorited) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onToggleFavorite()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.browser_recent_remove)) },
|
||||
leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onRemove()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A site's captured favicon, falling back to a glyph (a star for favorites, the globe otherwise). */
|
||||
@Composable
|
||||
private fun SiteIcon(
|
||||
host: String,
|
||||
isFavorite: Boolean,
|
||||
iconKeys: Set<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
|
||||
val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public
|
||||
val tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
if (model == null) {
|
||||
Icon(symbol, contentDescription = null, modifier = modifier, tint = tint)
|
||||
} else {
|
||||
val glyph = rememberMaterialSymbolPainter(symbol, tint)
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = null,
|
||||
modifier = modifier.clip(RoundedCornerShape(6.dp)),
|
||||
placeholder = glyph,
|
||||
error = glyph,
|
||||
fallback = glyph,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The host of [url] for a favorite's default label, falling back to the raw string. */
|
||||
private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url
|
||||
|
||||
+56
@@ -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) }
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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,
|
||||
)
|
||||
+14
@@ -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() {}
|
||||
}
|
||||
|
||||
+42
@@ -51,6 +51,7 @@ import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width.
|
||||
private val OFFSCREEN_SHIFT = 10_000.dp
|
||||
@@ -147,6 +148,47 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Loading / error overlay for the active tab, drawn AFTER the surfaces so it covers the active
|
||||
// one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay
|
||||
// can't live in the screen. A spinner until a real page paints, or an error+retry when the load
|
||||
// failed or stalled, so a slow / blank / failed load isn't a bare black/white void.
|
||||
val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller
|
||||
if (activeController != null && bounds.width > 0f && bounds.height > 0f) {
|
||||
var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) }
|
||||
var timedOut by remember(activeId) { mutableStateOf(false) }
|
||||
DisposableEffect(activeId, activeController) {
|
||||
activeController.onLoadStatusChanged = { loadStatus = it }
|
||||
onDispose { activeController.onLoadStatusChanged = null }
|
||||
}
|
||||
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
|
||||
LaunchedEffect(activeId, loadStatus) {
|
||||
timedOut = false
|
||||
if (!loadStatus.hasLoadedReal && !loadStatus.failed) {
|
||||
delay(12_000)
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
if (!loadStatus.hasLoadedReal) {
|
||||
with(density) {
|
||||
Box(
|
||||
Modifier
|
||||
.absoluteOffset(
|
||||
(bounds.left - layerOrigin.x).toDp(),
|
||||
(bounds.top - layerOrigin.y).toDp(),
|
||||
).size(bounds.width.toDp(), bounds.height.toDp()),
|
||||
) {
|
||||
EmbeddedLoadOverlay(
|
||||
failed = loadStatus.failed || timedOut,
|
||||
onRetry = {
|
||||
timedOut = false
|
||||
activeController.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The active tab's top pull-down sheet, drawn AFTER the surfaces so it sits on top of the
|
||||
// (z-below) surface, anchored to the top of the active tab's reserved bounds. Its expanded state
|
||||
// is owned here (reset per tab) so we can draw a full-area dismiss scrim behind the open sheet —
|
||||
|
||||
+36
@@ -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
|
||||
|
||||
+32
-8
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -60,10 +61,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
@@ -141,25 +144,45 @@ fun FavoriteAppsGrid(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(apps, key = { it.id }) { app ->
|
||||
FavoriteAppCell(
|
||||
app = app,
|
||||
onOpen = { onOpen(app) },
|
||||
onRemove = { onRemove(app) },
|
||||
)
|
||||
}
|
||||
favoriteAppItems(apps, onOpen, onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the favorite-app cells into any [LazyVerticalGrid] (the Favorite Apps tab, the browser home),
|
||||
* so callers can mix them with their own headers/sections in a single grid.
|
||||
*/
|
||||
fun LazyGridScope.favoriteAppItems(
|
||||
apps: List<FavoriteApp>,
|
||||
onOpen: (FavoriteApp) -> Unit,
|
||||
onRemove: (FavoriteApp) -> Unit,
|
||||
) {
|
||||
items(apps, key = { it.id }) { app ->
|
||||
FavoriteAppCell(
|
||||
app = app,
|
||||
onOpen = { onOpen(app) },
|
||||
onRemove = { onRemove(app) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FavoriteAppCell(
|
||||
internal fun FavoriteAppCell(
|
||||
app: FavoriteApp,
|
||||
onOpen: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// For a plain web favorite, prefer the favicon captured when its site was opened; nsites/napplets keep
|
||||
// their manifest icon. Observing the key set recomputes the model as an icon arrives.
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
val faviconModel =
|
||||
remember(app, iconKeys) {
|
||||
(app as? FavoriteApp.WebUrl)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -183,6 +206,7 @@ private fun FavoriteAppCell(
|
||||
app = app,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(28.dp),
|
||||
iconModel = faviconModel,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
@@ -672,7 +672,12 @@
|
||||
<string name="browser_tor_on">Loading over Tor. Tap to use the open web.</string>
|
||||
<string name="browser_tor_off">Loading over the open web. Tap to use Tor.</string>
|
||||
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
|
||||
<string name="embedded_tab_load_failed">Couldn\'t load this app.</string>
|
||||
<string name="browser_go">Open</string>
|
||||
<string name="browser_clear">Clear</string>
|
||||
<string name="browser_favorites">Favorites</string>
|
||||
<string name="browser_recent_options">Options</string>
|
||||
<string name="browser_recent_remove">Remove from history</string>
|
||||
<string name="favorite_apps">Favorite apps</string>
|
||||
<string name="favorite_apps_empty">No favorite apps yet. Open a web client or nsite and tap the star to pin it here.</string>
|
||||
<string name="favorite_app_add">Add to favorites</string>
|
||||
|
||||
+114
@@ -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()
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.browser
|
||||
|
||||
/**
|
||||
* Ranks omnibox autocomplete suggestions out of the user's favorites and visit history, and computes the
|
||||
* inline ghost-text completion the address bar shows. Pure and platform-agnostic (the caller maps its
|
||||
* favorites/history into [Candidate]s), so it lives in commons and is unit-tested directly.
|
||||
*
|
||||
* Ranking, roughly: a host-prefix match beats a path-prefix match beats a substring match; favorites are
|
||||
* boosted over plain history; ties break on visit frequency then recency. The result is host-deduplicated
|
||||
* (one row per site) so a frequently-visited site doesn't flood the list.
|
||||
*/
|
||||
object OmniboxSuggestions {
|
||||
/** A neutral candidate the caller builds from a favorite or a history entry. */
|
||||
data class Candidate(
|
||||
val url: String,
|
||||
val label: String,
|
||||
val isFavorite: Boolean,
|
||||
val visitCount: Int = 0,
|
||||
val lastVisitedAt: Long = 0L,
|
||||
)
|
||||
|
||||
data class Suggestion(
|
||||
val url: String,
|
||||
val label: String,
|
||||
val host: String,
|
||||
val isFavorite: Boolean,
|
||||
)
|
||||
|
||||
fun rank(
|
||||
typedRaw: String,
|
||||
candidates: List<Candidate>,
|
||||
limit: Int = 8,
|
||||
): List<Suggestion> {
|
||||
val typed = typedRaw.trim().lowercase()
|
||||
if (typed.isEmpty()) return emptyList()
|
||||
return candidates
|
||||
.mapNotNull { c ->
|
||||
val host = OmniboxInput.hostOf(c.url) ?: c.url
|
||||
val score = score(typed, c, host) ?: return@mapNotNull null
|
||||
Scored(Suggestion(c.url, c.label, host, c.isFavorite), score, c.lastVisitedAt)
|
||||
}.sortedWith(compareByDescending<Scored> { it.score }.thenByDescending { it.lastVisitedAt })
|
||||
.distinctBy { it.suggestion.host.lowercase() }
|
||||
.take(limit)
|
||||
.map { it.suggestion }
|
||||
}
|
||||
|
||||
/**
|
||||
* The host to inline-complete [typedRaw] to (the suffix the address bar pre-selects as ghost text), or
|
||||
* null when there's nothing to offer. Only completes a bare host fragment (no scheme, no path, no
|
||||
* spaces) against a ranked host that starts with it — so typing `git` offers `github.com`.
|
||||
*/
|
||||
fun completion(
|
||||
typedRaw: String,
|
||||
ranked: List<Suggestion>,
|
||||
): String? {
|
||||
val typed = typedRaw.trim()
|
||||
if (typed.isEmpty() || typed.any { it.isWhitespace() }) return null
|
||||
if (typed.contains("://") || typed.contains('/')) return null
|
||||
val lower = typed.lowercase()
|
||||
return ranked
|
||||
.firstOrNull { it.host.length > typed.length && it.host.lowercase().startsWith(lower) }
|
||||
?.host
|
||||
}
|
||||
|
||||
private fun score(
|
||||
typed: String,
|
||||
c: Candidate,
|
||||
host: String,
|
||||
): Double? {
|
||||
val h = host.lowercase()
|
||||
val u = c.url.lowercase()
|
||||
val urlNoScheme = u.substringAfter("://", u)
|
||||
val l = c.label.lowercase()
|
||||
var base =
|
||||
when {
|
||||
h.startsWith(typed) -> 1000.0
|
||||
urlNoScheme.startsWith(typed) -> 800.0
|
||||
h.contains(typed) -> 400.0
|
||||
l.contains(typed) -> 300.0
|
||||
u.contains(typed) -> 200.0
|
||||
else -> return null
|
||||
}
|
||||
if (c.isFavorite) base += 500.0
|
||||
base += minOf(c.visitCount, 50) * 2.0
|
||||
return base
|
||||
}
|
||||
|
||||
private data class Scored(
|
||||
val suggestion: Suggestion,
|
||||
val score: Double,
|
||||
val lastVisitedAt: Long,
|
||||
)
|
||||
}
|
||||
+8
-6
@@ -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,
|
||||
|
||||
+78
@@ -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"))
|
||||
}
|
||||
}
|
||||
+87
@@ -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))
|
||||
}
|
||||
}
|
||||
+124
-3
@@ -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"
|
||||
|
||||
+11
@@ -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"
|
||||
|
||||
+48
-10
@@ -34,6 +34,7 @@ import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.util.Log
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
@@ -44,6 +45,7 @@ import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -82,6 +84,10 @@ class NappletBrowserService : Service() {
|
||||
var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
var fireSeq = 0
|
||||
|
||||
// Last main-frame error state, pushed to the client so it can show an error/retry overlay over
|
||||
// the surface (the embedded surface has no error page of its own).
|
||||
var loadFailed = false
|
||||
|
||||
// Per visited origin: its broker-minted launch token, the requests queued until it arrives, and
|
||||
// the origins a mint is already in flight for — so NIP-07 consent is scoped per site, per tab.
|
||||
val originTokens = mutableMapOf<String, String>()
|
||||
@@ -278,7 +284,12 @@ class NappletBrowserService : Service() {
|
||||
view: WebView,
|
||||
url: String,
|
||||
favicon: android.graphics.Bitmap?,
|
||||
) = pushUrl(tab, view)
|
||||
) {
|
||||
// A new main-frame navigation cleared any prior error.
|
||||
tab?.loadFailed = false
|
||||
pushUrl(tab, view)
|
||||
pushLoadState(tab, view, isLoading = true)
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(
|
||||
view: WebView,
|
||||
@@ -289,7 +300,40 @@ class NappletBrowserService : Service() {
|
||||
override fun onPageFinished(
|
||||
view: WebView,
|
||||
url: String,
|
||||
) = pushUrl(tab, view)
|
||||
) {
|
||||
pushUrl(tab, view)
|
||||
pushLoadState(tab, view, isLoading = false)
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
// Only a main-frame failure blanks the page; sub-resource errors (a missing image, a blocked
|
||||
// tracker) are irrelevant to whether the app opened.
|
||||
if (!request.isForMainFrame) return
|
||||
tab?.loadFailed = true
|
||||
pushLoadState(tab, view, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
|
||||
private fun pushLoadState(
|
||||
tab: BrowserTab?,
|
||||
view: WebView,
|
||||
isLoading: Boolean,
|
||||
) {
|
||||
val message =
|
||||
Message.obtain(null, NappletBrowserContract.MSG_LOAD_STATE).apply {
|
||||
data =
|
||||
Bundle().apply {
|
||||
putBoolean(NappletBrowserContract.KEY_IS_LOADING, isLoading)
|
||||
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, tab?.loadFailed ?: false)
|
||||
putString(NappletBrowserContract.KEY_URL, view.url.orEmpty())
|
||||
}
|
||||
}
|
||||
runCatching { tab?.clientMessenger?.send(message) }
|
||||
}
|
||||
|
||||
private fun pushUrl(
|
||||
@@ -455,14 +499,8 @@ class NappletBrowserService : Service() {
|
||||
|
||||
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
|
||||
|
||||
/** Address-bar text → URL: keep an explicit scheme, prefix a bare domain, else DuckDuckGo search. */
|
||||
private fun normalizeUrl(input: String): String {
|
||||
val text = input.trim()
|
||||
if (text.isEmpty()) return "about:blank"
|
||||
if (text.contains("://")) return text
|
||||
if (!text.contains(' ') && text.contains('.')) return "https://$text"
|
||||
return "https://duckduckgo.com/?q=" + Uri.encode(text)
|
||||
}
|
||||
/** Address-bar text → URL via the shared [OmniboxInput] rules (bare domain → https, else search). */
|
||||
private fun normalizeUrl(input: String): String = OmniboxInput.resolve(input)?.url ?: "about:blank"
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "NappletBrowserService"
|
||||
|
||||
+87
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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"
|
||||
|
||||
|
||||
+47
-1
@@ -35,6 +35,7 @@ import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
@@ -99,6 +100,10 @@ class NappletHostService : Service() {
|
||||
var webView: WebView? = null
|
||||
var bridgeReplyProxy: JavaScriptReplyProxy? = null
|
||||
var fireSeq = 0
|
||||
|
||||
// Last main-frame error state, pushed to the client so it can show an error/retry overlay over the
|
||||
// surface (the embedded surface has no error page of its own).
|
||||
var loadFailed = false
|
||||
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
|
||||
}
|
||||
|
||||
@@ -311,6 +316,16 @@ class NappletHostService : Service() {
|
||||
request: WebResourceRequest,
|
||||
): WebResourceResponse? = tab.contentServer?.serve(request)
|
||||
|
||||
override fun onPageStarted(
|
||||
view: WebView,
|
||||
url: String,
|
||||
favicon: android.graphics.Bitmap?,
|
||||
) {
|
||||
// A new main-frame navigation cleared any prior error.
|
||||
tab.loadFailed = false
|
||||
pushLoadState(tab, isLoading = true)
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(
|
||||
view: WebView,
|
||||
url: String,
|
||||
@@ -320,7 +335,22 @@ class NappletHostService : Service() {
|
||||
override fun onPageFinished(
|
||||
view: WebView,
|
||||
url: String,
|
||||
) = pushState(tab, view)
|
||||
) {
|
||||
pushState(tab, view)
|
||||
pushLoadState(tab, isLoading = false)
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
// Only a main-frame failure blanks the applet; a missing sub-resource is irrelevant to whether
|
||||
// it opened.
|
||||
if (!request.isForMainFrame) return
|
||||
tab.loadFailed = true
|
||||
pushLoadState(tab, isLoading = false)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
@@ -346,6 +376,22 @@ class NappletHostService : Service() {
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
}
|
||||
|
||||
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
|
||||
private fun pushLoadState(
|
||||
tab: NappletTab,
|
||||
isLoading: Boolean,
|
||||
) {
|
||||
val message =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply {
|
||||
data =
|
||||
Bundle().apply {
|
||||
putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading)
|
||||
putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed)
|
||||
}
|
||||
}
|
||||
runCatching { tab.clientMessenger?.send(message) }
|
||||
}
|
||||
|
||||
// ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
|
||||
|
||||
private fun onShellMessage(
|
||||
|
||||
@@ -73,9 +73,37 @@ object NappletIpc {
|
||||
*/
|
||||
const val MSG_SET_WEB_TOR = 8
|
||||
|
||||
/**
|
||||
* Host → broker (browser mode): record a *successfully loaded* page in the device-local visit history
|
||||
* (main process only). Carries [KEY_HISTORY_URL] (the landed URL) and [KEY_HISTORY_TITLE]. Sent only
|
||||
* after a clean main-frame page-finish — never for a typed-but-failed address — so misspellings never
|
||||
* enter history. The `:napplet` process can't touch the main process's store, so it relays it here.
|
||||
*/
|
||||
const val MSG_RECORD_HISTORY = 9
|
||||
|
||||
/**
|
||||
* Host → broker (browser mode): store the favicon for a visited site. Carries [KEY_ICON_HOST] and
|
||||
* [KEY_ICON_BYTES] (a small PNG, scaled down by the host before sending). Captured from the WebView
|
||||
* that loaded the page, so it rides the page's own (Tor-routed) network path; the main process never
|
||||
* fetches it itself. Bytes stay well under the Binder transaction limit.
|
||||
*/
|
||||
const val MSG_RECORD_ICON = 10
|
||||
|
||||
const val KEY_REQUEST_ID = "requestId"
|
||||
const val KEY_PAYLOAD = "payload"
|
||||
|
||||
/** The landed URL of a successfully loaded browser page, for the visit-history record. */
|
||||
const val KEY_HISTORY_URL = "historyUrl"
|
||||
|
||||
/** The page title of a successfully loaded browser page, for the visit-history record. */
|
||||
const val KEY_HISTORY_TITLE = "historyTitle"
|
||||
|
||||
/** The host a captured favicon belongs to. */
|
||||
const val KEY_ICON_HOST = "iconHost"
|
||||
|
||||
/** The captured favicon as PNG bytes. */
|
||||
const val KEY_ICON_BYTES = "iconBytes"
|
||||
|
||||
/** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */
|
||||
const val KEY_WEB_HOST = "webHost"
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
|
||||
<string name="napplet_chrome_permissions_desc">What this app can access</string>
|
||||
<string name="napplet_chrome_reload">Reload</string>
|
||||
<!-- Browser address bar (direct-WebView browser only) -->
|
||||
<string name="browser_address_hint">Search or enter address</string>
|
||||
<string name="napplet_action_published">“%1$s” published a note as you</string>
|
||||
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
|
||||
<string name="napplet_action_paid">“%1$s” made a payment</string>
|
||||
|
||||
Reference in New Issue
Block a user