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" />
+
+
+
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() }
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
index da634a5ecf..558c750bb7 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
@@ -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,
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}")
+ }
+ }
+}