Skip to content
Merged
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ docs/validation/context-workflows/** -text -whitespace

# Preserve command handoff evidence and native capture bytes.
docs/validation/command-handoff/** -text -whitespace

# Preserve paste-copy evidence and native capture bytes.
docs/validation/paste-copy/** -text -whitespace

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,40 @@ class ReviewedTextDeviceTest {
}
private fun contextObservation(web: WebView): JSONObject = JSONObject(JSONArray("[${contextEvaluate(web,
"JSON.stringify(globalThis.__ownedContextTranslationRead())")}]").getString(0))
private fun touchPasteFallback(tag: String, expectedLabel: String) {
val target = compose.onNodeWithTag(tag)
var attempts = 0
while (target.fetchSemanticsNode().boundsInRoot.let { visible ->
val full = fullBounds(target)
visible.height + 1 < full.height || visible.width + 1 < full.width
} && attempts++ < 30) {
compose.onNodeWithTag("palette-content-scroll").performTouchInput {
swipe(Offset(4f, height * .8f), Offset(4f, height * .2f), 300)
}
}
val label = compose.onNodeWithText(expectedLabel, useUnmergedTree = true).fetchSemanticsNode()
val layouts = mutableListOf<TextLayoutResult>()
instrumentation.runOnMainSync { assertTrue(requireNotNull(label.config[SemanticsActions.GetTextLayoutResult].action)(layouts)) }
val layout = layouts.single(); assertEquals(expectedLabel, layout.layoutInput.text.text)
assertTrue(label.size.height + 1 >= layout.multiParagraph.height)
val origin = compose.runOnUiThread { label.positionInRoot }; val visible = label.boundsInRoot
val lines = JSONArray()
for (line in 0 until layout.lineCount) {
assertFalse(layout.isLineEllipsized(line))
val left = origin.x + layout.getLineLeft(line); val right = origin.x + layout.getLineRight(line)
val top = origin.y + layout.getLineTop(line); val bottom = origin.y + layout.getLineBottom(line)
assertTrue(left >= visible.left - 1 && right <= visible.right + 1 && top >= visible.top - 1 && bottom <= visible.bottom + 1)
lines.put(JSONArray(listOf(left, top, right, bottom)))
}
val witnesses = record.optJSONArray("paste_fallback_label_witnesses") ?: JSONArray().also { record.put("paste_fallback_label_witnesses", it) }
witnesses.put(JSONObject().put("tag", tag).put("label", expectedLabel).put("line_bounds", lines).put("reveal_gestures", attempts))
touchContextAction(tag)
}
private fun observeContextTranslation(commandName: String) {
val catalog = foregroundActivity().store.extensions
val expectPasteCopy = InstrumentationRegistry.getArguments().getString("expectPasteCopyFallback") == "true"
assertTrue("Explicit Copy expectation belongs only to the paste variant", !expectPasteCopy || commandName == "instant-translate-paste")
var usedPasteReturn = false
val view = commandName in setOf("translate", "quick-translate", "quick-translate-view")
compose.waitUntil(30000) { catalog.runtimeError != null ||
(if (view) activeRoot(catalog.runtimeTree)?.optString("type") == "List" else contextEngine() != null) }
Expand Down Expand Up @@ -626,6 +658,22 @@ class ReviewedTextDeviceTest {
} else if (commandName == "instant-translate-paste" && expected.isNotBlank() && paste == expected && observed.getBoolean("pasteRejected")) {
assertEquals(sentinel, clipboardText()); capture("translation-paste-unavailable")
record.put("copy_exact", false).put("observed_phase_outcome", "partial-paste-unsupported").put("owned_service_output_matched_paste_attempt", true)
if (expectPasteCopy) {
val deadline = android.os.SystemClock.uptimeMillis() + 10000
while (compose.onAllNodesWithTag("extension-paste-copy").fetchSemanticsNodes().isEmpty() &&
android.os.SystemClock.uptimeMillis() < deadline) { compose.waitForIdle(); android.os.SystemClock.sleep(50) }
assertEquals("A rejected paste with owned output must offer Copy result instead", 1,
compose.onAllNodesWithTag("extension-paste-copy").fetchSemanticsNodes().size)
assertEquals(sentinel, clipboardText()); capture("paste-fallback-before-copy")
touchPasteFallback("extension-paste-copy", "Copy result instead")
compose.waitUntil(10000) { clipboardText() == expected }
compose.onNodeWithTag("extension-paste-title").assertTextEquals("Result copied")
compose.onNodeWithTag("extension-paste-copy").assertDoesNotExist()
dismissClipboardForEvidence(requireAction = true); capture("paste-fallback-exact-copy")
record.put("copy_exact", true).put("native_copy_was_explicit", true)
.put("observed_phase_outcome", "partial-paste-explicit-copy-fallback")
touchPasteFallback("extension-paste-return", "Return"); usedPasteReturn = true
}
} else if (commandName == "instant-translate-view" && expected.isNotBlank() && catalog.runtimeReady && catalog.runtimeStatus == expected) {
assertEquals(sentinel, clipboardText()); capture("translation-visible-feedback")
record.put("copy_exact", false).put("observed_phase_outcome", "working-display-observed-path")
Expand All @@ -642,7 +690,9 @@ class ReviewedTextDeviceTest {
record.put("native_detail_returned_to_results", true).put("edited_query_preserved_on_native_back", true)
capture("translation-results-return")
}
touchContextAction("extension-back"); awaitCaller()
if (expectPasteCopy) assertTrue("A witnessed explicit native Copy fallback is required", usedPasteReturn)
if (!usedPasteReturn) touchContextAction("extension-back")
awaitCaller()
assertEquals("hello", device.findObject(By.res("android:id/edit")).text)
assertEquals("Selection 0 to 5", device.findObject(By.res("android:id/text2")).text)
record.put("workflow_outcome", record.getString("observed_phase_outcome"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class ExtensionCatalog(private val context: Context) {
var runtimeError: String?
get() = runtimeErrorState
set(value) {
if (value != null) revokeReviewedText()
if (value != null) revokeReviewedText(preservePasteCopy = true)
runtimeErrorState = value
}
var runtimeStatus by mutableStateOf("")
Expand All @@ -100,6 +100,12 @@ class ExtensionCatalog(private val context: Context) {
private var invocationLaunchProperties = ordinaryLaunchProperties(emptyMap())
private var commandHandoffDepth = 0
private var preparedCommandLaunch: PreparedCommandLaunch? = null
internal enum class PasteCopyStatus { READY, COPIED, FAILED }
internal class PasteCopyOffer(
val producer: Any, val epoch: Int, val extension: InstalledExtension, val command: ExtensionCommand,
val output: ExtensionPasteCopy?, val status: PasteCopyStatus = PasteCopyStatus.READY,
)
internal var pasteCopyOffer by mutableStateOf<PasteCopyOffer?>(null); private set
private class PreparedText(val preview: TextInputPreview, val target: TextInputTarget, val epoch: Int)
private var preparedText: PreparedText? = null
internal class RuntimeText(val owner: Any, val target: TextInputTarget, val input: CommandTextInput?, val launchProperties: String)
Expand Down Expand Up @@ -177,6 +183,7 @@ class ExtensionCatalog(private val context: Context) {
private fun ordinaryLaunchProperties(args: Map<String, String>) = JSONObject().put("arguments", JSONObject(args)).put("launchType", "userInitiated").toString()
private fun begin(extension: InstalledExtension, command: ExtensionCommand, args: Map<String,String>, owner: Any, input: CommandTextInput?,
launchProperties: String = ordinaryLaunchProperties(args), handoffDepth: Int = 0) {
clearPasteCopy()
stopRuntime(owner)
preparedCommandLaunch = null
commandText?.let(reviewedText::release)
Expand Down Expand Up @@ -276,12 +283,42 @@ class ExtensionCatalog(private val context: Context) {
invocationOwner === owner && running?.first === extension && running?.second === command
internal fun attachRuntime(owner: Any, extension: InstalledExtension, command: ExtensionCommand, stop: () -> Unit): Any? {
if (!ownsRuntime(owner, extension, command)) return null
clearPasteCopy()
runtimeStop?.second?.invoke()
val registration = Any()
runtimeStop = registration to stop
return registration
}
internal fun detachRuntime(registration: Any?) { if (registration != null && runtimeStop?.first === registration) runtimeStop = null }
internal fun detachRuntime(registration: Any?) {
if (registration == null) return
clearPasteCopy(producer = registration)
if (runtimeStop?.first === registration) runtimeStop = null
}
internal fun clearPasteCopy(expected: PasteCopyOffer? = null, producer: Any? = null) {
val current = pasteCopyOffer ?: return
if (expected != null && current !== expected || producer != null && current.producer !== producer) return
current.output?.clear(); pasteCopyOffer = null
}
internal fun offerPasteCopy(registration: Any, owner: Any, extension: InstalledExtension,
command: ExtensionCommand, output: ExtensionPasteCopy): Boolean {
if (runtimeStop?.first !== registration || !ownsRuntime(owner, extension, command) || runtimeError != null) {
output.clear(); return false
}
clearPasteCopy()
pasteCopyOffer = PasteCopyOffer(registration, launchEpoch, extension, command, output)
return true
}
internal fun isCurrentPasteCopy(offer: PasteCopyOffer): Boolean = pasteCopyOffer === offer &&
launchEpoch == offer.epoch && running?.first === offer.extension && running?.second === offer.command
internal fun copyPasteResult(offer: PasteCopyOffer, copy: (String) -> Unit): Boolean {
if (!isCurrentPasteCopy(offer) || offer.status != PasteCopyStatus.READY) return false
val text = offer.output?.take() ?: return false
val status = try { copy(text); PasteCopyStatus.COPIED } catch (_: RuntimeException) { PasteCopyStatus.FAILED }
if (isCurrentPasteCopy(offer)) {
pasteCopyOffer = PasteCopyOffer(offer.producer, offer.epoch, offer.extension, offer.command, null, status)
}
return status == PasteCopyStatus.COPIED
}
private fun stopRuntime(nextOwner: Any) {
invocationOwner = nextOwner
val stop = runtimeStop?.second
Expand All @@ -291,7 +328,8 @@ class ExtensionCatalog(private val context: Context) {
internal fun releaseText(owner: Any) {
if (invocationOwner === owner) { commandText?.let(reviewedText::release); commandText = null }
}
private fun revokeReviewedText() {
private fun revokeReviewedText(preservePasteCopy: Boolean = false) {
if (!preservePasteCopy) clearPasteCopy()
stopRuntime(Any())
reviewedText.invalidate(); commandText = null; preparedText = null
preparedCommandLaunch = null; invocationLaunchProperties = ordinaryLaunchProperties(emptyMap()); commandHandoffDepth = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package dev.tinycast.android.extensions

import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject

/** Plain output held once in memory. This object never formats its contents for logging. */
internal class ExtensionPasteCopy private constructor(text: String) {
private var value: String? = text
val available: Boolean get() = value != null

fun take(): String? = value.also { value = null }
fun clear() { value = null }

companion object {
internal const val MAX_TEXT_UNITS = 64 * 1024
internal const val MAX_TEXT_BYTES = 128 * 1024
internal const val MAX_REQUEST_UNITS = 512 * 1024
internal const val MAX_REQUEST_BYTES = 512 * 1024

fun parse(raw: String): ExtensionPasteCopy {
require(raw.length <= MAX_REQUEST_UNITS && raw.toByteArray(Charsets.UTF_8).size <= MAX_REQUEST_BYTES) {
"This paste result exceeds the Android size limit"
}
boundedNesting(raw)
val args = try { JSONArray(raw) } catch (_: JSONException) {
throw IllegalArgumentException("Paste requires valid content parameters")
}
require(args.length() in 1..2) { "Paste requires content and optional options" }
if (args.length() == 2 && !args.isNull(1)) {
val options = args.optJSONObject(1)
require(options != null && options.keys().asSequence().all { it == "closeMainWindow" } &&
(!options.has("closeMainWindow") || options.opt("closeMainWindow") is Boolean)) {
"These paste options are unavailable on Android"
}
}
val text = when (val content = args.opt(0)) {
is String -> content
is Number -> {
require(content.toDouble().isFinite()) { "Paste requires finite text content" }
// The portable API serializes numbers with JSON.stringify. Keep
// that exact representation, including small/large exponents.
raw.substringAfter('[').trimStart().substringBefore(',').substringBefore(']').trim()
}
is JSONObject -> {
require(content.keys().asSequence().all { it in setOf("text", "html") } &&
content.opt("text") is String && (!content.has("html") || content.opt("html") is String)) {
"Only a plain text result can be offered for Copy on Android"
}
content.getString("text")
}
else -> throw IllegalArgumentException("Only a plain text result can be offered for Copy on Android")
}
require(text.length <= MAX_TEXT_UNITS) { "This paste result exceeds the Android text limit" }
var index = 0
while (index < text.length) {
val character = text[index++]
if (Character.isHighSurrogate(character)) {
require(index < text.length && Character.isLowSurrogate(text[index++])) { "Paste requires valid Unicode text" }
} else require(!Character.isLowSurrogate(character)) { "Paste requires valid Unicode text" }
}
require(text.toByteArray(Charsets.UTF_8).size <= MAX_TEXT_BYTES) { "This paste result exceeds the Android text limit" }
return ExtensionPasteCopy(text)
}

private fun boundedNesting(raw: String) {
var at = 0
fun invalid(): Nothing = throw IllegalArgumentException("Paste requires valid JSON content parameters")
fun space() { while (at < raw.length && raw[at] in " \t\r\n") at++ }
fun token(character: Char) { space(); if (at >= raw.length || raw[at++] != character) invalid() }
fun string() {
token('"')
while (at < raw.length) {
val character = raw[at++]
if (character == '"') return
if (character < ' ') invalid()
if (character == '\\') {
if (at >= raw.length) invalid()
when (raw[at++]) {
'"', '\\', '/', 'b', 'f', 'n', 'r', 't' -> Unit
'u' -> repeat(4) {
if (at >= raw.length || raw[at++] !in "0123456789abcdefABCDEF") invalid()
}
else -> invalid()
}
}
}
invalid()
}
fun number() {
val start = at
if (raw[at] == '-') at++
if (at >= raw.length) invalid()
if (raw[at] == '0') at++ else {
if (raw[at] !in '1'..'9') invalid()
while (at < raw.length && raw[at] in '0'..'9') at++
}
if (at < raw.length && raw[at] == '.') {
at++; val digits = at
while (at < raw.length && raw[at] in '0'..'9') at++
if (at == digits) invalid()
}
if (at < raw.length && raw[at] in "eE") {
at++; if (at < raw.length && raw[at] in "+-") at++
val digits = at
while (at < raw.length && raw[at] in '0'..'9') at++
if (at == digits) invalid()
}
if (raw.substring(start, at).toDoubleOrNull()?.isFinite() != true) invalid()
}
fun value(depth: Int) {
require(depth <= 4) { "Paste content is too deeply nested" }
space(); if (at >= raw.length) invalid()
when (raw[at]) {
'"' -> string()
'[' -> {
at++; space()
if (at < raw.length && raw[at] == ']') { at++; return }
while (true) {
value(depth + 1); space(); if (at >= raw.length) invalid()
when (raw[at++]) { ']' -> return; ',' -> Unit; else -> invalid() }
}
}
'{' -> {
at++; space()
if (at < raw.length && raw[at] == '}') { at++; return }
while (true) {
string(); token(':'); value(depth + 1); space(); if (at >= raw.length) invalid()
when (raw[at++]) { '}' -> return; ',' -> Unit; else -> invalid() }
}
}
'-', in '0'..'9' -> number()
else -> {
val literal = listOf("true", "false", "null").firstOrNull { raw.startsWith(it, at) } ?: invalid()
at += literal.length
}
}
}
space(); if (at >= raw.length || raw[at] != '[') invalid()
value(0); space(); if (at != raw.length) invalid()
}
}
}
Loading
Loading