mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+32
-4
@@ -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
|
||||
}
|
||||
|
||||
+22
-1
@@ -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>
|
||||
|
||||
+41
-7
@@ -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(
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+80
@@ -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"
|
||||
}
|
||||
+1
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user