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 08711fed43..cbabd7772c 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 e14cc25611..d1eb8f8e4f 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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() 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}") + } + } +}