Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7a1f264
Record merged catalog slice and add owned legacy-engine boundary test
moikdev Nov 5, 2026
d268d7d
Preserve the existing engine diagnostic and isolate recovery coverage
moikdev Nov 5, 2026
bd80077
Check WebView engine capabilities before runtime startup and offer na…
moikdev Nov 5, 2026
959f629
Establish API26 package absence from exact PackageManager inventory
moikdev Nov 5, 2026
cbdc71a
Claim the recovery lease on a never-launched legacy installation
moikdev Nov 5, 2026
c69e8d7
Bind native engine probes to UI-thread and exact Grid witnesses
moikdev Nov 5, 2026
136f73d
Allow bounded bridge and stale-recovery regression cohorts
moikdev Nov 5, 2026
392bcb7
Require real engine startup before lifecycle live host-call controls
moikdev Nov 5, 2026
04636ed
Wait for exact font restoration and use explicit native Back in touch…
moikdev Nov 5, 2026
fec0a4b
Give each engine failure an independent recovery lifetime
moikdev Nov 5, 2026
302f5e3
Exercise fresh recovery actions after an equivalent replacement failure
moikdev Nov 5, 2026
5ff271a
Bind equivalent-failure publication to the catalog current command ob…
moikdev Nov 5, 2026
0328732
Keep common control reflection compatible with the old app and journa…
moikdev Nov 5, 2026
1f559a9
Publish exact-engine recovery matrix and retained failure boundaries
moikdev Nov 5, 2026
b417d94
Preserve original WebView capture bytes and exclude private journal p…
moikdev Nov 5, 2026
b35efed
Prove synchronous preflight denial with supported owned fs.exists and…
moikdev Nov 5, 2026
52efcf8
Track owned runtime construction explicitly during probe cleanup
moikdev Nov 5, 2026
29a0a5a
Retain supported host probe responses before assertions
moikdev Nov 5, 2026
86f1af0
Publish supported-operation approval proof and matched guard-regressi…
moikdev Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ docs/validation/paste-copy/** -text -whitespace

# Preserve recorded-test receipts and native capture bytes.
docs/validation/recorded-tests/** -text -whitespace

# Preserve WebView engine evidence bytes, including native XML CRLF.
docs/validation/webview-engine/** -text -whitespace
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ class ReviewedTextLifecycleDeviceTest {
val command = catalog.running!!.second
directRuntime = ExtensionRuntime(it, catalog, extension, command)
bridge = directRuntime!!.Bridge()
directRuntime!!.start()
}
// The live control must pass the real engine gate and publish its
// owned fixture before direct host calls acquire execution authority.
awaitInput()
ui {
assertTrue(JSONObject(bridge.invokeSync("fs", "writeFile", write(liveFile))).getBoolean("ok"))
bridge.render("""{"type":"Detail","id":"live-control","props":{"markdown":"Owned live control"}}""")
assertEquals("live-control", catalog.runtimeTree!!.getString("id"))
Expand Down Expand Up @@ -320,6 +326,10 @@ class ReviewedTextLifecycleDeviceTest {
val second = approve(receive("Next owned input"), show = false)
ui {
directRuntime = ExtensionRuntime(it, catalog, extension, catalog.running!!.second)
directRuntime!!.start()
}
awaitInput("Next owned input")
ui {
assertFalse(JSONObject(directRuntime!!.Bridge().invokeSync("fs", "exists", JSONArray().put("/owned-denied-outside").toString())).getBoolean("ok"))
}
compose.waitForIdle(); rejected(second)
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,11 @@ class ExtensionCatalog(private val context: Context) {
var running by mutableStateOf<Pair<InstalledExtension, ExtensionCommand>?>(null)
var runtimeTree by mutableStateOf<JSONObject?>(null)
private var runtimeErrorState by mutableStateOf<String?>(null)
internal var runtimeEngineProblem by mutableStateOf<ExtensionEngineProblem?>(null); private set
var runtimeError: String?
get() = runtimeErrorState
set(value) {
runtimeEngineProblem = null
if (value != null) revokeReviewedText(preservePasteCopy = true)
runtimeErrorState = value
}
Expand Down Expand Up @@ -334,5 +336,17 @@ class ExtensionCatalog(private val context: Context) {
reviewedText.invalidate(); commandText = null; preparedText = null
preparedCommandLaunch = null; invocationLaunchProperties = ordinaryLaunchProperties(emptyMap()); commandHandoffDepth = 0
}
internal fun reportEngineProblem(owner: Any, extension: InstalledExtension, command: ExtensionCommand,
problem: ExtensionEngineProblem): Boolean {
if (!ownsRuntime(owner, extension, command) || runtimeError != null) return false
val epoch = launchEpoch
// Setting the error revokes input and stops the runtime. The native
// recovery belongs to this failed screen, not to the revoked JS owner.
runtimeError = "The JavaScript engine could not pass the startup check."
if (launchEpoch != epoch || running?.first !== extension || running?.second !== command) return false
runtimeTree = null
runtimeEngineProblem = problem
return true
}
fun close() { revokeReviewedText(); running = null; runtimeTree = null; runtimeError = null; runtimeStatus = ""; runtimeReady = false }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package dev.tinycast.android.extensions

import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener

internal data class ExtensionEngineIdentity(val packageName: String?, val version: String?) {
val settingsPackage: String? get() = packageName?.takeIf {
it.length <= 255 && it.matches(Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+"))
}
val display: String get() = listOfNotNull(settingsPackage,
version?.filterNot(Char::isISOControl)?.trim()?.take(120)?.takeIf(String::isNotBlank))
.joinToString(" ").ifBlank { "Provider information unavailable" }
}

internal enum class ExtensionEngineFailure { SYNTAX, MISSING_APIS, CHECK_UNAVAILABLE, CHECK_TIMED_OUT }
// Each failure owns a fresh recovery lifetime, even when its display values
// equal a preceding failure. Compose must not retain a consumed action slot.
internal class ExtensionEngineProblem(val engine: ExtensionEngineIdentity, val failure: ExtensionEngineFailure,
val missingApis: List<String> = emptyList())

/** Only this ordered check can enable host dispatch. An ended check never reopens. */
internal class ExtensionEngineGate {
private enum class State { NEW, ADAPTER, RUNTIME, APIS, APPROVED, BLOCKED, CLOSED }
@Volatile private var state = State.NEW
val approved: Boolean get() = state == State.APPROVED
val stage: ExtensionEngineProbe.Stage? get() = when (state) {
State.ADAPTER -> ExtensionEngineProbe.Stage.ADAPTER
State.RUNTIME -> ExtensionEngineProbe.Stage.RUNTIME
State.APIS -> ExtensionEngineProbe.Stage.APIS
else -> null
}
@Synchronized fun begin(): Boolean {
if (state != State.NEW) return false
state = State.ADAPTER
return true
}
@Synchronized fun accept(stage: ExtensionEngineProbe.Stage): Boolean {
state = when {
state == State.ADAPTER && stage == ExtensionEngineProbe.Stage.ADAPTER -> State.RUNTIME
state == State.RUNTIME && stage == ExtensionEngineProbe.Stage.RUNTIME -> State.APIS
state == State.APIS && stage == ExtensionEngineProbe.Stage.APIS -> State.APPROVED
else -> return false
}
return true
}
@Synchronized fun block() { if (state != State.CLOSED) state = State.BLOCKED }
@Synchronized fun close() { state = State.CLOSED }
}

/** Probe wrappers use ES5 syntax. Modern vendor source appears only in escaped strings. */
internal object ExtensionEngineProbe {
enum class Stage(val wire: String) { ADAPTER("adapter"), RUNTIME("runtime"), APIS("apis") }
sealed interface Result {
data object Passed : Result
data class Rejected(val failure: ExtensionEngineFailure, val missingApis: List<String> = emptyList()) : Result
}

// Browser primitives used by the bundled adapter/runtime, not desktop APIs
// supplied by the host or browser facilities replaced by the portable runtime.
private val functions = listOf("Map", "Set", "WeakMap", "WeakSet", "Proxy", "Symbol", "Symbol.for",
"Promise", "Promise.all", "Promise.resolve", "Promise.reject", "ArrayBuffer", "Uint8Array",
"Array.from", "Array.isArray", "Array.prototype.includes", "Array.prototype.at",
"Object.assign", "Object.create", "Object.defineProperties", "Object.defineProperty", "Object.entries",
"Object.freeze", "Object.fromEntries", "Object.getOwnPropertyDescriptor", "Object.getOwnPropertyDescriptors",
"Object.getOwnPropertyNames", "Object.getPrototypeOf", "Object.hasOwn", "Object.is", "Object.keys", "Object.setPrototypeOf",
"Reflect.apply", "Reflect.construct", "Reflect.get", "Reflect.getOwnPropertyDescriptor", "Reflect.ownKeys",
"Number.isFinite", "Number.isInteger", "Number.isNaN", "Number.parseInt", "Math.clz32", "Math.trunc",
"String.fromCharCode", "String.fromCodePoint", "String.prototype.includes", "String.prototype.startsWith", "String.prototype.endsWith")
private val contract = functions.associateWith { "function" } + mapOf("globalThis" to "object", "Symbol.iterator" to "symbol")

private fun literal(value: String) = JSONObject.quote(value).replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
fun compileScript(stage: Stage, source: String): String {
require(stage != Stage.APIS && source.length <= 1_000_000)
val body = literal(source)
// Constructing the function parses its body; the returned function is
// never invoked. No extension bundle is supplied to this check.
return """(function(){var problem=null;try{new Function($body);}catch(error){problem=error instanceof SyntaxError?'syntax':'unavailable';}return JSON.stringify({v:1,stage:'${stage.wire}',problem:problem,missing:[]});})()"""
}
fun apiScript(): String {
val checks = JSONArray(contract.map { (path, type) -> JSONArray().put(path).put(type) })
return """(function(){var checks=$checks,missing=[],problem=null;try{for(var i=0;i<checks.length;i++){var parts=checks[i][0].split('.'),value=window;for(var j=0;j<parts.length;j++){if(value==null){value=undefined;break;}value=value[parts[j]];}if(typeof value!==checks[i][1])missing.push(checks[i][0]);}if(missing.length)problem='apis';}catch(error){problem='unavailable';missing=[];}return JSON.stringify({v:1,stage:'apis',problem:problem,missing:missing});})()"""
}
fun decode(raw: String?, expected: Stage): Result {
fun unavailable() = Result.Rejected(ExtensionEngineFailure.CHECK_UNAVAILABLE)
if (raw == null || raw.length > 8192) return unavailable()
return try {
val outer = JSONTokener(raw)
val text = outer.nextValue() as? String ?: return unavailable()
if (outer.nextClean() != '\u0000') return unavailable()
val inner = JSONTokener(text)
val value = inner.nextValue() as? JSONObject ?: return unavailable()
if (inner.nextClean() != '\u0000') return unavailable()
if (value.keys().asSequence().toSet() != setOf("v", "stage", "problem", "missing") ||
value.opt("v") != 1 || value.opt("stage") != expected.wire) return unavailable()
val array = value.opt("missing") as? JSONArray ?: return unavailable()
if (array.length() > contract.size) return unavailable()
val missing = (0 until array.length()).map { array.opt(it) as? String ?: return unavailable() }
if (missing.distinct().size != missing.size || missing.any { it !in contract }) return unavailable()
when (value.opt("problem")) {
JSONObject.NULL -> if (missing.isEmpty()) Result.Passed else unavailable()
"syntax" -> if (expected != Stage.APIS && missing.isEmpty()) Result.Rejected(ExtensionEngineFailure.SYNTAX) else unavailable()
"apis" -> if (expected == Stage.APIS && missing.isNotEmpty()) Result.Rejected(ExtensionEngineFailure.MISSING_APIS, missing) else unavailable()
"unavailable" -> unavailable()
else -> unavailable()
}
} catch (_: Exception) { unavailable() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package dev.tinycast.android.extensions

import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.tinycast.android.LocalPaletteWide
import dev.tinycast.android.PaletteTheme
import dev.tinycast.android.paletteFooterSurface

private class EngineRecoveryLifetime { var active = true; var returned = false }

/** The OS provider identity is the only input to this host-constructed intent. */
internal fun engineSettingsIntent(engine: ExtensionEngineIdentity): Intent = engine.settingsPackage?.let {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", it, null))
} ?: Intent(Settings.ACTION_APPLICATION_SETTINGS)

@Composable
internal fun ColumnScope.EngineRecovery(catalog: ExtensionCatalog, problem: ExtensionEngineProblem,
isCurrent: () -> Boolean, close: () -> Unit) {
val lifetime = remember(problem) { EngineRecoveryLifetime() }
var settingsOpened by remember(problem) { mutableStateOf(false) }
var settingsError by remember(problem) { mutableStateOf(false) }
val context = LocalContext.current
val window = LocalWindowInfo.current
val view = LocalView.current
val focus = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
DisposableEffect(lifetime) { onDispose { lifetime.active = false } }
LaunchedEffect(problem) {
if (window.isWindowFocused && isCurrent()) { focus.clearFocus(force = true); keyboard?.hide() }
}
fun current() = lifetime.active && !lifetime.returned && isCurrent() &&
catalog.runtimeError != null && catalog.runtimeEngineProblem === problem &&
window.isWindowFocused && view.isAttachedToWindow && view.hasWindowFocus()
val unsupported = problem.failure in setOf(ExtensionEngineFailure.SYNTAX, ExtensionEngineFailure.MISSING_APIS)
Column(Modifier.weight(1f).fillMaxWidth().testTag("extension-engine-problem")) {
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(if (unsupported) "WebView update needed" else "Couldn’t check WebView",
fontSize = 18.sp, lineHeight = 26.sp, color = PaletteTheme.ink,
modifier = Modifier.testTag("extension-engine-title"))
Text(problem.engine.display, fontSize = 13.sp, lineHeight = 20.sp, color = PaletteTheme.secondary,
modifier = Modifier.testTag("extension-engine-provider"))
Text(when (problem.failure) {
ExtensionEngineFailure.SYNTAX -> "This WebView cannot parse the bundled JavaScript runtime. The command was not started."
ExtensionEngineFailure.MISSING_APIS -> "This WebView is missing JavaScript features required by the bundled runtime. The command was not started."
ExtensionEngineFailure.CHECK_UNAVAILABLE -> "The startup check did not return a valid result. The command was not started."
ExtensionEngineFailure.CHECK_TIMED_OUT -> "The startup check did not finish in time. The command was not started."
}, fontSize = 14.sp, lineHeight = 22.sp, color = PaletteTheme.secondary,
modifier = Modifier.testTag("extension-engine-explanation"))
if (problem.missingApis.isNotEmpty()) Text("Missing: ${problem.missingApis.joinToString(", ")}",
fontSize = 12.sp, lineHeight = 19.sp, color = PaletteTheme.secondary)
Text("Update Android System WebView or Chrome through your device’s app store. Engine settings shows the provider used here. On older Android versions, an adequate update may be unavailable.",
fontSize = 14.sp, lineHeight = 22.sp, color = PaletteTheme.secondary,
modifier = Modifier.testTag("extension-engine-guidance"))
Text("Return, then run the command again after updating. Any selected text needs a new review.",
fontSize = 14.sp, lineHeight = 22.sp, color = PaletteTheme.secondary)
if (settingsError) Text("Couldn’t open engine settings. Open your device’s Settings app to manage WebView or Chrome.",
color = MaterialTheme.colorScheme.error, fontSize = 14.sp, lineHeight = 22.sp,
modifier = Modifier.testTag("extension-engine-settings-error"))
}
val settings: @Composable (Modifier) -> Unit = { modifier ->
OutlinedButton(onClick = {
if (current() && !settingsOpened) {
settingsOpened = true
try { context.startActivity(engineSettingsIntent(problem.engine)) }
catch (_: ActivityNotFoundException) { settingsOpened = false; settingsError = true }
catch (_: SecurityException) { settingsOpened = false; settingsError = true }
}
}, enabled = current() && !settingsOpened, shape = RoundedCornerShape(10.dp),
modifier = modifier.heightIn(min = 48.dp).testTag("extension-engine-settings")) {
Text("Engine settings", fontSize = 14.sp, lineHeight = 20.sp)
}
}
val back: @Composable (Modifier) -> Unit = { modifier ->
Button(onClick = { if (current()) { lifetime.returned = true; close() } }, enabled = current(),
shape = RoundedCornerShape(10.dp), modifier = modifier.heightIn(min = 48.dp).testTag("extension-engine-return")) {
Text("Return", fontSize = 14.sp, lineHeight = 20.sp)
}
}
if (LocalPaletteWide.current) {
Row(Modifier.fillMaxWidth().paletteFooterSurface().padding(20.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
settings(Modifier.weight(1f)); back(Modifier.weight(1f))
}
} else {
Column(Modifier.fillMaxWidth().paletteFooterSurface().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
settings(Modifier.fillMaxWidth()); back(Modifier.fillMaxWidth())
}
}
}
}
Loading
Loading