From 11e223778b63fbe419a015b51cc8dd07cde25090 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sat, 14 Nov 2026 18:04:50 +0000 Subject: [PATCH 1/2] feat(mobile): agents on the phone can open apps and send intents with am Agents on the phone could open URLs and files but not launch an app or send a broadcast, such as Tailscale's CONNECT_VPN. Adds Android's am (start, broadcast; also termux-am) and pm list packages to the bridge, behind a new "Other apps" category in Agent access. The app builds each intent from am's flags, so it carries no flags, URI grants, selector or Parcelable extras, can't target T3 Code itself, and can't place calls outside the Phone category. Broadcasts must name their app. A launcher-intent entry makes apps with an icon visible without QUERY_ALL_PACKAGES. Done with Claude Opus 5.5 in Claude Code. Co-Authored-By: Claude Opus 5.5 --- .../android/src/main/AndroidManifest.xml | 5 + .../src/main/assets/android-bridge.mjs | 76 ++++++++++++ .../src/main/assets/phone-agent-guide.md | 17 ++- .../modules/t3deviceruntime/AccessApproval.kt | 2 +- .../modules/t3deviceruntime/AgentAccess.kt | 12 +- .../modules/t3deviceruntime/AndroidBridge.kt | 28 ++++- .../modules/t3deviceruntime/AppIntents.kt | 108 ++++++++++++++++++ .../device-runtime/AgentAccessRouteScreen.tsx | 1 + .../android-bridge-script.test.ts | 48 +++++++- .../src/features/settings/settings-search.ts | 2 + docs/user/providers-claude.md | 6 +- 11 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml index adb61bfb51f7..7341d5ab37d7 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/AndroidManifest.xml @@ -33,6 +33,11 @@ + + + + + diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/assets/android-bridge.mjs b/apps/mobile/modules/t3-device-runtime/android/src/main/assets/android-bridge.mjs index b9fdb661b348..d5f59579fada 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/assets/android-bridge.mjs +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/assets/android-bridge.mjs @@ -155,6 +155,79 @@ const open = { }, }; +const AM_VALUES = { + "-a": "action", + "-d": "data", + "-t": "type", + "-n": "component", + "-p": "package", +}; +const AM_EXTRAS = { + "-e": "string", + "--es": "string", + "--ez": "boolean", + "--ei": "int", + "--el": "long", + "--ef": "float", +}; + +/** Android's am flags. As there, a last argument is a URI, a package/class or a package. */ +const am = { + usage: + "start|broadcast [-a action] [-d uri] [-t type] [-c category]... [-n package/class] " + + "[-p package] [--es|--ez|--ei|--el|--ef key value]... [uri | package/class | package]", + request: ([mode, ...args]) => { + if (mode !== "start" && mode !== "broadcast") + throw new UsageError("expected start or broadcast"); + const body = { mode }; + const categories = []; + const extras = []; + let index = 0; + const value = (offset) => { + const found = args[index + offset]; + if (found === undefined) throw new UsageError(`${args[index]} needs a value`); + return found; + }; + while (index < args.length) { + const arg = args[index]; + if (AM_VALUES[arg]) { + body[AM_VALUES[arg]] = value(1); + index += 2; + } else if (arg === "-c") { + categories.push(value(1)); + index += 2; + } else if (AM_EXTRAS[arg]) { + extras.push({ type: AM_EXTRAS[arg], key: value(1), value: value(2) }); + index += 3; + } else if (arg.startsWith("-")) { + throw new UsageError(`unknown option ${arg}`); + } else if (index === args.length - 1) { + const field = URL_SCHEME.test(arg) ? "data" : arg.includes("/") ? "component" : "package"; + body[field] = arg; + index += 1; + } else { + throw new UsageError(`unexpected argument ${arg}`); + } + } + if (categories.length > 0) body.categories = categories; + if (extras.length > 0) body.extras = extras; + return { path: "am", body }; + }, +}; + +const pm = { + usage: "list packages [-3 | -s] [filter]", + request: ([list, packages, ...args]) => { + if (list !== "list" || packages !== "packages") { + throw new UsageError("only pm list packages is supported"); + } + const { options, rest } = parseOptions(args, {}, { "-3": "thirdParty", "-s": "system" }); + if (rest.length > 1) throw new UsageError("expected at most one filter"); + return { path: "pm", body: { ...options, ...(rest[0] ? { filter: rest[0] } : {}) } }; + }, + print: (names) => names.forEach((name) => printLine(`package:${name}`)), +}; + const VOLUME_STREAMS = ["alarm", "music", "notification", "ring", "system", "call"]; const SPEECH_STREAMS = ["ALARM", "MUSIC", "NOTIFICATION", "RING", "SYSTEM", "VOICE_CALL"]; const SMS_TYPES = ["all", "inbox", "sent", "draft", "outbox", "failed", "queued"]; @@ -516,6 +589,9 @@ const COMMANDS = { }, "termux-telephony-deviceinfo": jsonCommand("telephony-deviceinfo"), "termux-telephony-cellinfo": jsonCommand("telephony-cellinfo"), + am, + "termux-am": am, + pm, }; async function call(path, body) { diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/assets/phone-agent-guide.md b/apps/mobile/modules/t3-device-runtime/android/src/main/assets/phone-agent-guide.md index f9453e346ad0..29f7c91bb311 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/assets/phone-agent-guide.md +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/assets/phone-agent-guide.md @@ -23,11 +23,16 @@ There is no Termux, Termux:API app or `pkg`: install packages with `apt-get` (ru always allow or never): `termux-location`, `termux-telephony-cellinfo`, `termux-camera-photo`, `termux-microphone-record`, `termux-contact-list`, `termux-sms-list`, `termux-sms-send`, `termux-call-log`, `termux-telephony-call`, - `termux-telephony-deviceinfo`, `termux-notification-list`. With Ask each time, each run - waits up to a minute for the user to answer on the phone, and fails if nobody does. The - first use also shows Android's own permission prompt. If one fails because it's set to - Never, wasn't approved or Android wasn't allowed, pass on the place the error names - instead of working around it. + `termux-telephony-deviceinfo`, `termux-notification-list`, and `am` and `pm` (below). + With Ask each time, each run waits up to a minute for the user to answer on the phone, + and fails if nobody does. The first use also shows Android's own permission prompt. If + one fails because it's set to Never, wasn't approved or Android wasn't allowed, pass on + the place the error names instead of working around it. +- `am start` and `am broadcast` (also `termux-am`) send intents with Android's flags `-a`, + `-d`, `-t`, `-c`, `-n`, `-p`, `--es`, `--ez`, `--ei`, `--el` and `--ef`, and a last + argument that's a URI, package/class or package. `am start ` opens that app. + Broadcasts need `-p` or `-n`. `pm list packages [-3 | -s] [filter]` lists apps that have + a launcher icon; others are hidden from T3 Code. Calls go through `termux-telephony-call`. - While T3 Code isn't on screen, the clipboard can't be read, the camera, microphone and - location fail, and links, files, shares and calls arrive as a notification for the user + location fail, and links, files, apps, shares and calls arrive as a notification for the user to tap. diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt index 3dc3ca7e3b21..07335263e552 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AccessApproval.kt @@ -100,7 +100,7 @@ internal object AccessApproval { private fun waitingFor(category: AccessCategory, command: String, details: String): WaitingApproval { val message = buildString { - append("termux-$command") + append(AgentAccess.commandName(command)) if (details.isNotEmpty()) append("\n\n$details") append("\n\nAn agent or another device using this phone's server asked for this.") } diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt index 25bbcf23d955..fee30dc3f25a 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AgentAccess.kt @@ -64,8 +64,18 @@ object AgentAccess { ), // Notification access is a special permission, granted in system settings. AccessCategory(NOTIFICATIONS, "Notifications", listOf("notification-list"), emptyList()), + AccessCategory("apps", "Other apps", listOf("am", "pm"), emptyList()), ) + /** The guest commands that reach a bridge command: termux-, but am and pm keep Android's names. */ + fun guestCommands(command: String): List = when (command) { + "am" -> listOf("am", "termux-am") + "pm" -> listOf("pm") + else -> listOf("termux-$command") + } + + fun commandName(command: String) = guestCommands(command).first() + fun category(id: String): AccessCategory? = categories.firstOrNull { it.id == id } fun policy(context: Context, category: AccessCategory): AccessPolicy { @@ -153,7 +163,7 @@ object AgentAccess { } private fun rules(category: AccessCategory) = - category.commands.flatMap { listOf("Bash(termux-$it)", "Bash(termux-$it *)") } + category.commands.flatMap(::guestCommands).flatMap { listOf("Bash($it)", "Bash($it *)") } private fun preferences(context: Context) = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) } diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt index f5de6e420bee..b5de15101765 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AndroidBridge.kt @@ -27,6 +27,7 @@ import android.webkit.URLUtil import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.FileProvider +import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import java.io.ByteArrayOutputStream @@ -85,6 +86,9 @@ private val GUEST_COMMANDS = listOf( "termux-telephony-call", "termux-telephony-deviceinfo", "termux-telephony-cellinfo", + "am", + "termux-am", + "pm", ) // Commands that wait on hardware, an engine or a database, so they run on the request's thread. @@ -104,6 +108,7 @@ private val WAITING_COMMANDS = setOf( "telephony-deviceinfo", "telephony-cellinfo", "notification-list", + "pm", ) private const val NOTIFICATION_CHANNEL = "t3-agent-notifications" private const val REQUEST_CHANNEL = "t3-agent-requests" @@ -305,9 +310,28 @@ class AndroidBridge private constructor(context: Context, private val rootfs: Fi "telephony-deviceinfo" -> data.deviceInfo() "telephony-cellinfo" -> data.cellInfo() "notification-list" -> data.notifications() + "am" -> am(body) + "pm" -> JSONArray(launchablePackages(context, body)) else -> throw BridgeError("Unknown command: $command", 404) }.takeUnless { it == Unit } + private fun am(body: JSONObject): Any? { + val intent = appIntent(context, body) + return when (val mode = body.getString("mode")) { + "start" -> launchIntent(context, intent).let { startOrNotify(it, "Open an app from an agent", describeIntent(it)) } + "broadcast" -> { + requireTarget(intent) + context.sendBroadcast(intent) + if (hasDeclaredReceiver(context, intent)) null + else JSONObject().put( + "message", + "Sent, but no app on this phone declares a receiver for it, so nothing may have received it.", + ) + } + else -> throw BridgeError("am takes start or broadcast, not $mode") + } + } + private fun openUrl(url: String): Any? = startOrNotify(Intent(Intent.ACTION_VIEW, webUri(url)), "Open a link from an agent", url) @@ -424,13 +448,13 @@ class AndroidBridge private constructor(context: Context, private val rootfs: Fi context.startActivity(intent) } else { val tap = PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_IMMUTABLE) - notify(REQUEST_CHANNEL, REQUEST_TAG, notificationId, "Allow $label?", "An agent wants to run termux-$command. Tap to answer.", tap) + notify(REQUEST_CHANNEL, REQUEST_TAG, notificationId, "Allow $label?", "An agent wants to run ${AgentAccess.commandName(command)}. Tap to answer.", tap) } }, dismiss = { notificationManager().cancel(REQUEST_TAG, notificationId) }, ) if (!approved) { - throw BridgeError("The user didn't allow termux-$command on the phone. ${AgentAccess.where(category)} controls this.", 403) + throw BridgeError("The user didn't allow ${AgentAccess.commandName(command)} on the phone. ${AgentAccess.where(category)} controls this.", 403) } } diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt new file mode 100644 index 000000000000..2828b5660b30 --- /dev/null +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt @@ -0,0 +1,108 @@ +package expo.modules.t3deviceruntime + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.net.Uri +import org.json.JSONObject + +// T3 Code holds CALL_PHONE, so these would place a call outside the Phone category. +private val CALL_ACTIONS = setOf( + Intent.ACTION_CALL, + "android.intent.action.CALL_PRIVILEGED", + "android.intent.action.CALL_EMERGENCY", +) + +/** + * The intent an am command describes. Android sends it as T3 Code, with T3 Code's permissions, + * so it's built from am's flags rather than parsed: it carries no flags, URI grants, selector or + * Parcelable extras, and can't reach T3 Code's own components. + */ +internal fun appIntent(context: Context, body: JSONObject): Intent { + val intent = Intent() + body.optString("action").takeIf { it.isNotEmpty() }?.let { intent.action = it } + if (intent.action in CALL_ACTIONS) throw BridgeError("Calls go through termux-telephony-call.") + val data = body.optString("data").takeIf { it.isNotEmpty() }?.let(Uri::parse) + intent.setDataAndType(data, body.optString("type").takeIf { it.isNotEmpty() }) + body.optJSONArray("categories")?.let { categories -> + for (index in 0 until categories.length()) intent.addCategory(categories.getString(index)) + } + body.optString("component").takeIf { it.isNotEmpty() }?.let { name -> + intent.component = ComponentName.unflattenFromString(name) + ?: throw BridgeError("-n takes package/class, such as com.example/.MainActivity, not $name") + } + body.optString("package").takeIf { it.isNotEmpty() }?.let { intent.setPackage(it) } + body.optJSONArray("extras")?.let { extras -> + for (index in 0 until extras.length()) putExtra(intent, extras.getJSONObject(index)) + } + val own = context.packageName + if (intent.`package` == own || intent.component?.packageName == own) { + throw BridgeError("am can't send intents to T3 Code itself.") + } + if (data?.scheme == "content" && data?.authority.orEmpty().startsWith(own)) { + throw BridgeError("T3 Code's own files go through termux-open or termux-share.") + } + return intent +} + +/** am start -p with nothing else opens the app, as its launcher icon does. */ +internal fun launchIntent(context: Context, intent: Intent): Intent { + val target = intent.`package` + val onlyPackage = target != null && intent.action == null && intent.data == null && + intent.component == null && intent.categories.isNullOrEmpty() + if (!onlyPackage) return intent + val launch = context.packageManager.getLaunchIntentForPackage(target) + ?: throw BridgeError("$target isn't installed or has no app to open. pm list packages lists the ones that do.") + return launch.putExtras(intent) +} + +/** What a notification or error names the intent by. */ +internal fun describeIntent(intent: Intent): String = + intent.component?.flattenToShortString() ?: intent.`package` ?: intent.dataString ?: intent.action ?: "this intent" + +/** A broadcast names its app, so one can't reach every app that listens for its action. */ +internal fun requireTarget(intent: Intent) { + if (intent.`package` == null && intent.component == null) { + throw BridgeError("am broadcast needs -p or -n to name the app it's for.") + } +} + +/** Whether an installed app declares a receiver for it; apps can also register one while they run. */ +internal fun hasDeclaredReceiver(context: Context, intent: Intent): Boolean = + @Suppress("DEPRECATION") + context.packageManager.queryBroadcastReceivers(intent, 0).isNotEmpty() + +/** + * pm list packages, for the apps with a launcher icon: the manifest's queries make only those + * visible, as Play allows QUERY_ALL_PACKAGES for few kinds of app. + */ +internal fun launchablePackages(context: Context, body: JSONObject): List { + val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val filter = body.optString("filter") + @Suppress("DEPRECATION") + return context.packageManager.queryIntentActivities(launcher, 0) + .map { it.activityInfo.applicationInfo } + .filter { app -> + val isSystem = app.flags and ApplicationInfo.FLAG_SYSTEM != 0 + !(body.optBoolean("thirdParty") && isSystem) && !(body.optBoolean("system") && !isSystem) + } + .map { it.packageName } + .filter { filter in it } + .distinct() + .sorted() +} + +private fun putExtra(intent: Intent, extra: JSONObject) { + val key = extra.getString("key") + val value = extra.getString("value") + fun invalid(): Nothing = throw BridgeError("The extra $key takes a ${extra.getString("type")}, not $value") + when (extra.getString("type")) { + "string" -> intent.putExtra(key, value) + "boolean" -> intent.putExtra(key, value.toBooleanStrictOrNull() ?: invalid()) + "int" -> intent.putExtra(key, value.toIntOrNull() ?: invalid()) + "long" -> intent.putExtra(key, value.toLongOrNull() ?: invalid()) + "float" -> intent.putExtra(key, value.toFloatOrNull() ?: invalid()) + else -> throw BridgeError("Unknown extra type ${extra.getString("type")}") + } +} diff --git a/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx b/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx index f19f44485e84..6628d2ee00e6 100644 --- a/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx +++ b/apps/mobile/src/features/device-runtime/AgentAccessRouteScreen.tsx @@ -23,6 +23,7 @@ const CATEGORIES: Readonly { await using bridge = await startBridge((path) => { if (path === "/clipboard-get") return { ok: true, result: "copied" }; if (path === "/battery-status") return { ok: true, result: { percentage: 80 } }; + if (path === "/pm") return { ok: true, result: ["com.tailscale.ipn", "org.mozilla.firefox"] }; return { ok: true, result: { notified: true, message: "Tap the notification." } }; }); expect((await bridge.run("termux-clipboard-get", [])).stdout).toBe("copied"); expect((await bridge.run("termux-battery-status", [])).stdout).toBe( '{\n "percentage": 80\n}\n', ); + expect((await bridge.run("pm", ["list", "packages"])).stdout).toBe( + "package:com.tailscale.ipn\npackage:org.mozilla.firefox\n", + ); expect(await bridge.run("xdg-open", ["https://example.com"])).toEqual({ code: 0, stdout: "", @@ -219,9 +223,13 @@ describe("android-bridge.mjs", () => { await bridge.run("termux-volume", ["music", "loud"]), await bridge.run("termux-location", ["-p", "wifi"]), await bridge.run("termux-torch", ["bright"]), + await bridge.run("am", ["start", "-W", "com.example"]), + await bridge.run("am", ["broadcast", "--ez", "enabled"]), + await bridge.run("am", ["startservice", "-p", "com.example"]), + await bridge.run("pm", ["uninstall", "com.example"]), ]; expect(recording.stdout).toBe("Recording started\n"); - expect(refused.map(({ code }) => code)).toEqual([1, 1, 1, 1]); + expect(refused.map(({ code }) => code)).toEqual([1, 1, 1, 1, 1, 1, 1, 1]); expect(sent(bridge)).toEqual([ { path: "/microphone-record", @@ -231,6 +239,44 @@ describe("android-bridge.mjs", () => { ]); }); + it("reads am's intent flags, and a last argument as a URI, component or package", async () => { + await using bridge = await startBridge(succeed); + await bridge.run("am", [ + "broadcast", + "-a", + "com.tailscale.ipn.CONNECT_VPN", + "--ez", + "quiet", + "true", + "-c", + "a", + "-c", + "b", + "com.tailscale.ipn/.IPNReceiver", + ]); + await bridge.run("termux-am", ["start", "-a", "android.intent.action.VIEW", "geo:0,0?q=cafe"]); + await bridge.run("am", ["start", "com.tailscale.ipn"]); + await bridge.run("pm", ["list", "packages", "-3", "tail"]); + expect(sent(bridge)).toEqual([ + { + path: "/am", + body: { + mode: "broadcast", + action: "com.tailscale.ipn.CONNECT_VPN", + component: "com.tailscale.ipn/.IPNReceiver", + categories: ["a", "b"], + extras: [{ type: "boolean", key: "quiet", value: "true" }], + }, + }, + { + path: "/am", + body: { mode: "start", action: "android.intent.action.VIEW", data: "geo:0,0?q=cafe" }, + }, + { path: "/am", body: { mode: "start", package: "com.tailscale.ipn" } }, + { path: "/pm", body: { thirdParty: true, filter: "tail" } }, + ]); + }); + it("reads sensors once per -n and cleans up without calling Android", async () => { await using bridge = await startBridge(() => ({ ok: true, diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 2137a0355f60..240f7507c6f6 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -134,6 +134,8 @@ const SETTINGS_SEARCH_TERMS = { "sms", "call log", "notifications", + "apps", + "intents", ], } satisfies Partial>; const termsById: ReadonlyMap = new Map( diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 164427f2366b..a425d46f0397 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -74,8 +74,10 @@ phone becomes an environment named after the device. `termux-share`, `termux-download`, `termux-tts-speak`, `termux-location` and `termux-camera-photo`. While T3 Code isn't on screen, links, files and calls arrive as a notification to tap, and the clipboard, camera, microphone and location can't be used. -- Commands that reach your location, camera, microphone, contacts, SMS, call log, phone or - notifications ask you first, as an approval in the thread, even with full access. The +- Agents can open other apps and send them intents with Android's `am start` and + `am broadcast`, and list apps with `pm list packages`. +- Commands that reach your location, camera, microphone, contacts, SMS, call log, phone, + notifications or other apps ask you first on the phone, even with full access. The first time, Android also asks you to give T3 Code the permission. In **Settings > Agent access** you can set each to **Always allow** or **Never**. If you installed T3 Code from an APK file, Android may block notification access until you turn on **Allow restricted From ba73ca276ae25fd8c50e9ce4df110d0a954eb4b2 Mon Sep 17 00:00:00 2001 From: Tim Biggs Date: Sat, 14 Nov 2026 19:48:01 +0000 Subject: [PATCH 2/2] fix(mobile): am's bad-extra error reads as English Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/expo/modules/t3deviceruntime/AppIntents.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt index 2828b5660b30..becb1551ef73 100644 --- a/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt +++ b/apps/mobile/modules/t3-device-runtime/android/src/main/java/expo/modules/t3deviceruntime/AppIntents.kt @@ -96,7 +96,7 @@ internal fun launchablePackages(context: Context, body: JSONObject): List intent.putExtra(key, value) "boolean" -> intent.putExtra(key, value.toBooleanStrictOrNull() ?: invalid())