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