Merge pull request #4208 from vitorpamplona/feat/browser-name-the-browser

feat(browser): name the browser the hand-off tile will open
This commit is contained in:
Vitor Pamplona
2026-09-26 20:59:23 -04:00
committed by GitHub
11 changed files with 395 additions and 12 deletions
+11
View File
@@ -17,6 +17,17 @@
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- So the browser's hand-off tile can name the browser it would open.
Android 11+ package visibility answers resolveActivity with nothing
for a scheme that is not declared here, so without this the tile can
only ever say "Open in browser". Read-only: it names the target, the
hand-off itself still goes through a chooser. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<!-- NIP-A3 payment targets. Android 11+ package visibility means
queryIntentActivities returns NOTHING for a scheme not declared here,
so without these the zap picker's pay-to chip is invisible on every
@@ -101,8 +101,9 @@ object BrowserHistoryRegistry {
) {
val host = OmniboxInput.hostOf(url) ?: url
val now = System.currentTimeMillis()
val key = historyKey(url)
update { current ->
val existing = current.firstOrNull { it.url == url }
val existing = current.firstOrNull { historyKey(it.url) == key }
val entry =
if (existing != null) {
existing.copy(
@@ -114,18 +115,22 @@ object BrowserHistoryRegistry {
} else {
BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1)
}
(listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES)
(listOf(entry) + current.filterNot { historyKey(it.url) == key }).take(MAX_ENTRIES)
}
}
fun remove(url: String) = update { current -> current.filterNot { it.url == url } }
fun remove(url: String) =
update { current ->
val key = historyKey(url)
current.filterNot { historyKey(it.url) == key }
}
fun clear() = update { emptyList() }
private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> =
list
.sortedByDescending { it.lastVisitedAt }
.distinctBy { it.url }
.distinctBy { historyKey(it.url) }
.take(MAX_ENTRIES)
private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) {
@@ -152,3 +157,26 @@ object BrowserHistoryRegistry {
emptyList()
}
}
/**
* What counts as "the same page" in the history list.
*
* Keying on the raw URL string put `primal.net` in the list twice: a trailing slash, a different case
* in the host, or a leftover `#fragment` reads as one page and compares as two Strings. So the scheme
* and host are lowercased, a fragment is dropped, and a trailing slash is dropped when the path is
* nothing but that slash.
*
* The path and the query are kept and compared as-is. Two pages of the same site are two entries, and
* guessing which query parameters are load-bearing is how you lose one of them.
*/
internal fun historyKey(url: String): String {
val noFragment = url.substringBefore('#')
val schemeEnd = noFragment.indexOf("://")
if (schemeEnd <= 0) return noFragment
val scheme = noFragment.substring(0, schemeEnd).lowercase()
val rest = noFragment.substring(schemeEnd + 3)
val authorityEnd = rest.indexOfFirst { it == '/' || it == '?' }
val authority = (if (authorityEnd < 0) rest else rest.substring(0, authorityEnd)).lowercase()
val tail = if (authorityEnd < 0) "" else rest.substring(authorityEnd)
return scheme + "://" + authority + if (tail == "/") "" else tail
}
@@ -745,7 +745,11 @@ private fun RecentRow(
overflow = TextOverflow.Ellipsis,
)
Text(
entry.host,
// Host and path, not just the host: two pages of one site usually carry the
// same <title>, so `primal.net/home` and `primal.net` arrived as two rows
// reading "Primal / primal.net" and there was no way to tell them apart or
// to know which one a tap would open.
remember(entry.url, entry.host) { recentSubtitle(entry.url, entry.host) },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -818,3 +822,20 @@ private fun SiteIcon(
/** 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
/**
* The second line of a Recent row: what a reader needs to tell two rows of one site apart.
*
* The scheme and a bare trailing slash are noise at this size, so they go; everything after the
* host stays, because that is the part that differs. Falls back to the host when the URL has
* nothing more to say.
*/
internal fun recentSubtitle(
url: String,
host: String,
): String {
val schemeEnd = url.indexOf("://")
val afterScheme = if (schemeEnd > 0) url.substring(schemeEnd + 3) else url
val trimmed = afterScheme.removeSuffix("/")
return trimmed.ifBlank { host }
}
@@ -0,0 +1,69 @@
/*
* 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 org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/** The list showed `primal.net` twice; these are the pairs that produced that. */
class BrowserHistoryKeyTest {
@Test
fun `a trailing slash on the root is the same page`() {
assertEquals(historyKey("https://primal.net"), historyKey("https://primal.net/"))
}
@Test
fun `the host is compared without case`() {
assertEquals(historyKey("https://Primal.NET/"), historyKey("https://primal.net/"))
}
@Test
fun `a fragment is not a different page`() {
assertEquals(historyKey("https://primal.net/home"), historyKey("https://primal.net/home#top"))
}
@Test
fun `a path is still its own page`() {
assertNotEquals(historyKey("https://primal.net/"), historyKey("https://primal.net/home"))
}
@Test
fun `a query is still its own page`() {
assertNotEquals(historyKey("https://x.com/search?q=a"), historyKey("https://x.com/search?q=b"))
}
@Test
fun `a trailing slash deeper in the path is left alone`() {
// Servers are free to treat these as different, so we do not decide for them.
assertNotEquals(historyKey("https://primal.net/home"), historyKey("https://primal.net/home/"))
}
@Test
fun `http and https are different origins`() {
assertNotEquals(historyKey("http://primal.net/"), historyKey("https://primal.net/"))
}
@Test
fun `something that is not a url is returned as itself`() {
assertEquals("not a url", historyKey("not a url"))
}
}
@@ -0,0 +1,62 @@
/*
* 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 com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.recentSubtitle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
/** The two rows that both read "Primal / primal.net" and could not be told apart. */
class RecentSubtitleTest {
@Test
fun `two pages of one site no longer read the same`() {
assertNotEquals(
recentSubtitle("https://primal.net/", "primal.net"),
recentSubtitle("https://primal.net/home", "primal.net"),
)
}
@Test
fun `the root shows the bare host`() {
assertEquals("primal.net", recentSubtitle("https://primal.net/", "primal.net"))
}
@Test
fun `a path is shown after the host`() {
assertEquals("primal.net/home", recentSubtitle("https://primal.net/home", "primal.net"))
}
@Test
fun `a port is kept, since it is part of where you are going`() {
assertEquals("localhost:8000/t.html", recentSubtitle("http://localhost:8000/t.html", "localhost"))
}
@Test
fun `a query is kept`() {
assertEquals("x.com/search?q=nostr", recentSubtitle("https://x.com/search?q=nostr", "x.com"))
}
@Test
fun `a url with nothing to add falls back to the host`() {
assertEquals("primal.net", recentSubtitle("", "primal.net"))
}
}
@@ -5374,6 +5374,7 @@
<string name="browser_pill_add_home">Add to Home</string>
<string name="browser_pill_desktop">Desktop site</string>
<string name="browser_pill_other_browser">Open in browser</string>
<string name="browser_pill_other_browser_named">Open in %1$s</string>
<string name="browser_pill_full_screen">Full screen</string>
<string name="browser_pill_left_site">You left %1$s</string>
<string name="browser_pill_back_to_app">Back to app</string>
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_close
import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allowed
import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_blocked
import com.vitorpamplona.amethyst.commons.resources.browser_pill_left_site
import com.vitorpamplona.amethyst.commons.resources.browser_pill_other_browser_named
import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_state
import com.vitorpamplona.amethyst.commons.resources.browser_pill_privacy
import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings_none
@@ -352,7 +353,7 @@ private fun OutOfScopeBanner(
}
}
/** Page actions as tiles, four per row; toggles (desktop site, text size) fill while on. */
/** Page actions as tiles, at most four per row; toggles (desktop site, text size) fill while on. */
@Composable
private fun TileGrid(
actions: List<Action>,
@@ -360,15 +361,26 @@ private fun TileGrid(
textSizeOpen: Boolean,
onAction: (Action) -> Unit,
) {
val columns = 4
// Spread over the rows we are going to use anyway, rather than filling each
// one to four and leaving the remainder stranded. Six actions read as 3 + 3
// instead of 4 + 2 with a hole beside it, and five as 3 + 2 instead of 4 + 1.
// Every row is chunked to the same width, so the tiles stay a uniform size
// and a gap appears only when the count is not divisible — seven is 4 + 3
// either way.
var taken = 0
val rows = balancedRowSizes(actions.size, MAX_TILE_COLUMNS).map { size -> actions.subList(taken, taken + size).also { taken += size } }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
actions.chunked(columns).forEach { rowActions ->
rows.forEach { rowActions ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowActions.forEach { action ->
// The hand-off tile names the browser it will actually open when the
// system will name one; "Open in browser" is what is left when the
// answer is a chooser. See BrowserPillUi.defaultBrowserName.
val named = ui.defaultBrowserName?.takeIf { action == Action.OPEN_IN_BROWSER_APP }
ActionTile(
symbol = pillSymbolFor(action) ?: MaterialSymbols.Info,
label = stringRes(pillTileLabelFor(action)),
description = stringRes(pillLabelFor(action)),
label = named?.let { stringRes(Res.string.browser_pill_other_browser_named, it) } ?: stringRes(pillTileLabelFor(action)),
description = named?.let { stringRes(Res.string.browser_pill_other_browser_named, it) } ?: stringRes(pillLabelFor(action)),
onClick = { onAction(action) },
selected =
when (action) {
@@ -379,13 +391,35 @@ private fun TileGrid(
modifier = Modifier.weight(1f),
)
}
// Keep the last row's tiles the same width as the others.
repeat(columns - rowActions.size) { Spacer(Modifier.weight(1f)) }
// Keep every row's tiles the same width, whatever the row holds.
repeat(MAX_TILE_COLUMNS - rowActions.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
/** Most tiles on one row. Four is what fits at the pill's width without the labels wrapping twice. */
private const val MAX_TILE_COLUMNS = 4
/**
* How many tiles go on each row, spread as evenly as the count allows.
*
* The number of rows is fixed by [max] either way — this only decides how the
* items are shared out between them, so the leftovers do not all land on the
* last row. Ten tiles are 4 + 3 + 3, not 4 + 4 + 2. Rows are padded back out
* to [max] where they draw, so the tiles stay a uniform width throughout.
*/
internal fun balancedRowSizes(
count: Int,
max: Int,
): List<Int> {
if (count <= 0) return emptyList()
val rows = (count + max - 1) / max
val base = count / rows
val remainder = count % rows
return List(rows) { index -> base + if (index < remainder) 1 else 0 }
}
/** Text size: a stepped slider between a small and a large "A", the value, and a way back to 100%. */
@Composable
fun TextSizeControl(
@@ -44,6 +44,13 @@ data class BrowserPillUi(
val consoleErrors: Int = 0,
/** Answers this site already has (camera / mic / location), for the site-settings summary. */
val sitePermissions: Map<BrowserSitePermission, BrowserSitePermission.Decision> = emptyMap(),
/**
* The default browser's name, when the system will name one and it is not us.
*
* Null means the hand-off shows a chooser, and the tile has to stay "Open in browser":
* no default is set, the device hides it, or the only handler is Amethyst itself.
*/
val defaultBrowserName: String? = null,
) {
val security: BrowserChrome.Security get() = BrowserChrome.security(chrome)
val host: String get() = BrowserChrome.displayHost(chrome.url)
@@ -0,0 +1,80 @@
/*
* 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.ui.pill
import kotlin.test.Test
import kotlin.test.assertEquals
class BalancedRowSizesTest {
private fun rows(
count: Int,
max: Int = 4,
) = balancedRowSizes(count, max)
@Test
fun `six fills two rows evenly instead of stranding two`() {
assertEquals(listOf(3, 3), rows(6))
}
@Test
fun `five is three and two, not four and one`() {
assertEquals(listOf(3, 2), rows(5))
}
@Test
fun `seven cannot be even and stays four and three`() {
assertEquals(listOf(4, 3), rows(7))
}
@Test
fun `a full row is left alone`() {
assertEquals(listOf(4), rows(4))
assertEquals(listOf(4, 4), rows(8))
}
@Test
fun `no row is ever wider than the maximum, and every tile is placed`() {
(1..24).forEach { count ->
val rows = rows(count)
assertEquals(true, rows.all { it in 1..4 }, "count=$count rows=$rows")
assertEquals(count, rows.sum(), "count=$count rows=$rows")
}
}
@Test
fun `rows never differ by more than one`() {
(1..24).forEach { count ->
val rows = rows(count)
assertEquals(true, rows.max() - rows.min() <= 1, "count=$count rows=$rows")
}
}
@Test
fun `it never puts everything on one row it cannot fit`() {
assertEquals(listOf(4, 4, 4), rows(12))
assertEquals(listOf(4, 3, 3), rows(10))
}
@Test
fun `an empty grid does not divide by zero`() {
assertEquals(emptyList(), balancedRowSizes(0, 4))
}
}
@@ -0,0 +1,69 @@
/*
* 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.napplethost
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import androidx.core.net.toUri
/**
* Who "open in a browser" would actually hand the page to.
*
* Only used to say so on the tile. The hand-off itself still goes through a chooser, so a wrong
* or missing answer here costs a label, never a mis-launch.
*/
object DefaultBrowser {
/** A probe URL: the scheme is what selects browsers, the host is never contacted. */
private val PROBE = Intent(Intent.ACTION_VIEW, "https://example.com".toUri()).addCategory(Intent.CATEGORY_BROWSABLE)
/**
* The default browser's app label, or null when the tile should stay generic.
*
* Null in three cases that all mean the same thing to a reader — you are going to get a
* chooser, so do not promise a name:
* - no default is set, and the system resolves to its own picker;
* - the only handler is Amethyst, so "open in a browser" means anything but us;
* - package visibility hides it. Android 11+ answers `resolveActivity` with nothing unless
* the manifest declares a matching `<queries>` entry, which is why one exists for
* http/https alongside the payment schemes.
*/
fun label(context: Context): String? =
runCatching {
val pm = context.packageManager
@Suppress("DEPRECATION")
val match: ResolveInfo = pm.resolveActivity(PROBE, PackageManager.MATCH_DEFAULT_ONLY) ?: return null
val pkg = match.activityInfo?.packageName ?: return null
// The system picker resolves for everything; naming it would be a lie.
if (pkg == context.packageName || isResolver(match)) return null
match.loadLabel(pm).toString().takeIf { it.isNotBlank() }
}.getOrNull()
/**
* Whether this is Android's own chooser rather than a browser.
*
* `resolveActivity` returns the resolver when several apps match and none is default; it
* reports `exported=false` from the `android` package, which is the cheap way to tell.
*/
private fun isResolver(info: ResolveInfo): Boolean = info.activityInfo?.packageName == "android"
}
@@ -1337,6 +1337,7 @@ class NappletBrowserActivity : ComponentActivity() {
torOn = if (proxyPort > 0) useTor else null,
),
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
defaultBrowserName = DefaultBrowser.label(this),
),
listener = chromeListener,
)