mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
fix(browser): the two crashes that make the new chrome unusable on a device
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 `<provider>` 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d52d54ac3f
commit
9d0dd96de7
@@ -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"
|
||||
|
||||
+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
@@ -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()
|
||||
|
||||
+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