From 9d0dd96de705098fad9e34e844335f6d7de0a897 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 26 Sep 2026 17:41:08 -0400 Subject: [PATCH 1/2] fix(browser): the two crashes that make the new chrome unusable on a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduce on the first page you open. Neither is visible to the build: lint, Android Lint, the metadata compiles, the native compile and the whole test suite are green with both present. **Any page with a favicon killed the full-screen browser.** `TaskDescription.Builder.setIcon(Icon)` exists from API **37**; below that the Builder only takes a drawable resource id. We compile against 37, so it resolves, and the guard was `SDK_INT >= TIRAMISU` — so Android 13, 14, 15 and 16 all called a method their framework does not have: NoSuchMethodError: No virtual method setIcon(Landroid/graphics/drawable/Icon;) at NappletBrowserActivity.updateTaskDescription(...:1186) at BrowserChromeClient.onReceivedIcon(...:520) The `runCatching` there wraps `setTaskDescription`, not the building, so it caught nothing. Gated on 37; everything below keeps the deprecated constructor, which takes the same three things and carries the bitmap anyway. Android Lint's NewApi did not flag this, which is worth knowing: compiling against a preview SDK makes its whole surface look available. **Opening the pill killed the sandbox.** The chrome is now drawn by shared composables that read `Res.string`, and those run in `:napplet`: MissingResourceException: ... Android context is not initialized. Compose Resources learns its Context from a ContentProvider the library declares, and a provider is only instantiated in the process that owns it. Three things do not work here and are worth recording so they are not retried: a second `` element (the manifest merger keys them by `android:name` and merges the two into one), a subclass with its own authority (`AndroidContextProvider` is `internal` *and* final), and `PreviewContextConfigurationEffect()`, which the exception text suggests but which sets the Context from an effect that runs *after* composition — while `stringRes` starts its async load during it. What works is the pair: `android:multiprocess="true"` lets each process hold its own instance, and acquiring the provider once in the sandbox's two activities is the first access that makes Android create it. After that every lookup resolves in-process, with no IPC. `multiprocess` alone is not enough — the instance is lazy, and nothing in `:napplet` ever addresses that authority. Verified on an SM-T220 (API 34): the full-screen browser loads a page, and the pill opens with every string, the Tor state and the site-settings summary rendered. Co-Authored-By: Claude Opus 5 (1M context) --- amethyst/src/main/AndroidManifest.xml | 16 +++++ .../napplethost/NappletBrowserActivity.kt | 13 +++- .../napplethost/NappletHostActivity.kt | 1 + .../napplethost/SandboxComposeResources.kt | 59 +++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index b120f60ad1..dd42a938dd 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -640,6 +640,22 @@ android:name=".service.call.CallNotificationReceiver" android:exported="false" /> + + + = Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT >= ICON_BUILDER_SDK) { ActivityManager.TaskDescription .Builder() .setLabel(label) @@ -1637,6 +1645,9 @@ class NappletBrowserActivity : ComponentActivity() { private const val EXTRA_IS_FAVORITE = "isFavorite" private const val EXTRA_POPUP_TOKEN = "popupToken" + /** `TaskDescription.Builder.setIcon(Icon)` exists from this SDK on. */ + private const val ICON_BUILDER_SDK = 37 + fun intent( context: Context, url: String, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index b16ab84463..b8f57894a0 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -259,6 +259,7 @@ class NappletHostActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + SandboxComposeResources.ensure(this) if (!readManifestExtras()) { Toast.makeText(this, getString(R.string.napplet_invalid), Toast.LENGTH_SHORT).show() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt new file mode 100644 index 0000000000..f0688d8af6 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt @@ -0,0 +1,59 @@ +/* + * 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 com.vitorpamplona.quartz.utils.Log + +/** + * Gives this process the Context that Compose Resources needs. + * + * The browser chrome is drawn by shared composables that read `Res.string`, and + * they run here, in `:napplet`. Compose Resources learns its Context from a + * ContentProvider the library declares, and a provider is only instantiated in + * the process that owns it — so every string lookup in the sandbox died with + * MissingResourceException, taking the window with it. + * + * `android:multiprocess="true"` (set on that provider in the app manifest) lets + * each process hold its own instance, but Android creates it lazily, on first + * access. Nothing in `:napplet` ever addresses the provider by authority, so + * without this it is never created. Acquiring a client once is that first + * access; the provider's `onCreate` then records this process's Context and + * every later lookup resolves locally, with no IPC. + * + * Call before anything composes. It is cheap and idempotent. + */ +object SandboxComposeResources { + private var done = false + + fun ensure(context: Context) { + if (done) return + done = true + val authority = "${context.packageName}.resources.AndroidContextProvider" + runCatching { + context.contentResolver.acquireContentProviderClient(authority)?.close() + }.onFailure { + // Not fatal on its own: the failure surfaces later as a missing + // string, which is easier to read with this line above it. + Log.w("SandboxComposeResources", "could not warm $authority: ${it.message}") + } + } +} From a930fc97f883d764b38682d22da0e20802785251 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 26 Sep 2026 18:02:53 -0400 Subject: [PATCH 2/2] design(browser): let the collapsed handle go quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle is on screen the whole time a page is, and almost nobody pulls it down — it is there for when you are stuck on a site, not as a status display. Tinting it with the Tor accent lit it up on every page, because onion routing is the normal case here rather than the exception, and an accent that is always on is one nobody reads. So it spends colour only on what a reader should act on: plain HTTP keeps the error colour, the console-error dot stays (5dp at 80%, down from a solid 7dp), and everything else — Tor included — is the same muted grey. Tor is not hidden; it keeps its badge inside the pill, where the address row says "Onion-routed" in words for anyone who opens it. I also tried dropping the handle's background, and put it back: on a dark page it nearly vanished, and the point is to call less attention, not to be undiscoverable. `BrowserPillRenderTest` needed a note. Its "did anything render" guard is a flat `distinct colours > 20` over the whole canvas, and `05-handles` — three small bars on mostly empty space — fell to 18 once the accent came off. That guard was in effect asserting the handle has a saturated accent, which is the thing being removed. Rather than lower it for all eleven images, that one gets `minColours = 12`: a blank render scores 1-3, so it still catches the failure the guard exists for. Co-Authored-By: Claude Opus 5 (1M context) --- .../commons/browser/ui/pill/BrowserPill.kt | 20 ++++++++++++------- .../browser/ui/pill/BrowserPillRenderTest.kt | 10 ++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt index 3c261ee149..2c93718755 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt @@ -141,9 +141,16 @@ fun BrowserPill( } /** - * The collapsed grabber. It tells the page's story before it's opened: the bar takes the error colour on - * plain HTTP and the Tor accent when onion-routed, a hairline fills while the page loads, and a dot - * appears when the console has errors. + * The collapsed grabber. + * + * It is on screen the whole time a page is, and almost nobody pulls it down: it is there for when you + * are stuck on a site, not as a status display. So it stays quiet, and spends colour only on the states + * a reader should act on. + * + * Onion routing is not one of them. It is the normal case here rather than an exception, and an accent + * that is always lit is one nobody reads — it just makes the handle loud on every page. Tor keeps its + * badge inside the pill, where the address and "Onion-routed" say it in words for anyone who opens it. + * What is left in the handle is plain HTTP, which is a warning, and a dot for console errors. */ @Composable fun PillHandle( @@ -155,8 +162,7 @@ fun PillHandle( val barColor = when (ui.security) { BrowserChrome.Security.HTTP -> MaterialTheme.colorScheme.error - BrowserChrome.Security.TOR -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } Box( modifier @@ -200,9 +206,9 @@ fun PillHandle( Modifier .align(Alignment.CenterEnd) .padding(start = 44.dp) - .size(7.dp) + .size(5.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.error), + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.8f)), ) } } diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt index 3ac0d504e9..d449a33afb 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -42,6 +42,7 @@ class BrowserPillRenderTest { name: String, widthDp: Int, heightDp: Int, + minColours: Int = 20, content: @Composable () -> Unit, ) { val density = 2f @@ -61,7 +62,7 @@ class BrowserPillRenderTest { val pixels = image.peekPixels() ?: error("no pixels for $name") val distinct = HashSet() for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y) - assertTrue(distinct.size > 20, "$name rendered almost nothing (${distinct.size} colours)") + assertTrue(distinct.size > minColours, "$name rendered almost nothing (${distinct.size} colours)") } finally { scene.close() } @@ -75,7 +76,12 @@ class BrowserPillRenderTest { @Test fun napplet() = render("04-napplet", 820, 800) { BrowserPillNappletPreview() } - @Test fun handles() = render("05-handles", 820, 160) { PillHandlesPreview() } + // Three small bars on a mostly empty canvas, and deliberately the quietest thing + // the redesign draws — so it clears the "did anything render" bar by less than the + // full screens do. It scored 18 when the accent came off; a blank render is 1-3, so + // 12 still catches the failure this guard is for without demanding a colour the + // component is not supposed to have. + @Test fun handles() = render("05-handles", 820, 160, minColours = 12) { PillHandlesPreview() } @Test fun find() = render("06-find", 820, 220) { FindInPagePreview() }