Merge the sandbox crash fixes into claude/relaxed-maxwell-hlc0a2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ
This commit is contained in:
Claude
2026-09-26 22:54:23 +00:00
6 changed files with 109 additions and 10 deletions
+16
View File
@@ -640,6 +640,22 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
<!--
Compose Resources learns its Context from a ContentProvider the library
declares, and a provider is instantiated only in the process it belongs
to. The browser chrome is now drawn by shared composables that read
`Res.string`, and those run in `:napplet` — where the lookup threw
MissingResourceException ("Android context is not initialized") and killed
the sandbox. `multiprocess` is the platform's answer to exactly this: it
gives every process of the app its own instance of the provider. A second
<provider> element cannot work, because the manifest merger keys them by
android:name and would merge the two into one.
-->
<provider
android:name="org.jetbrains.compose.resources.AndroidContextProvider"
android:multiprocess="true"
tools:node="merge" />
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
@@ -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)),
)
}
}
@@ -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<Int>()
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() }
@@ -268,6 +268,7 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SandboxComposeResources.ensure(this)
// A new window a page opened: its WebView already exists (built inside the opener's onCreateWindow).
val popupToken = intent.getStringExtra(EXTRA_POPUP_TOKEN)
@@ -1178,8 +1179,15 @@ class NappletBrowserActivity : ComponentActivity() {
private fun updateTaskDescription() {
val label = pageTitle ?: title.ifBlank { null } ?: BrowserChrome.displayHost(currentUrl())
val color = themeColor ?: 0
// The Builder's `setIcon` that takes an `Icon` is API 37. We compile
// against 37, so it resolves, and the old guard was `TIRAMISU` — which
// meant every device from 33 to 36 called a method its framework does
// not have and died with NoSuchMethodError the moment a page delivered
// a favicon. Lint's NewApi did not flag it. The deprecated constructor
// takes the same three things and carries the bitmap, so it stays the
// path for everything below 37.
val description =
if (Build.VERSION.SDK_INT >= 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,
@@ -258,6 +258,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()
@@ -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}")
}
}
}