From e9fbb853df45f587f6d8519ba0ae4488d5a4eb75 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 29 Sep 2026 17:28:39 +0100 Subject: [PATCH] Open Android app after installation --- bin/README.md | 13 +++-- bin/config.sh | 2 +- bin/launch-app | 38 +++++++++++++ iterable-provision/SKILL.md | 2 +- iterable-verify/SKILL.md | 32 +++++------ tests/launch-app.sh | 108 ++++++++++++++++++++++++++++++++++++ tests/no-bare-commands.sh | 2 +- 7 files changed, 172 insertions(+), 25 deletions(-) create mode 100755 bin/launch-app create mode 100755 tests/launch-app.sh diff --git a/bin/README.md b/bin/README.md index d5ca888..5caab40 100644 --- a/bin/README.md +++ b/bin/README.md @@ -67,13 +67,13 @@ so `bin/agent` provisions nothing and sends nothing. It names the script; the ca Every screen the wizard shows has a twin in `next.question`, including registering a missing Android app and picking the device by name, so the chat is the whole walk rather than a router to -the terminal. `run_app` keeps its question even after the device is settled: signing in and -answering Android's notification dialog happen inside the developer's app, so the tool asks and -waits. +the terminal. `run_app` keeps its question even after the device is settled: `bin/launch-app` opens +the selected package, then signing in and answering Android's notification dialog happen inside +the developer's app, so the tool asks and waits. -**Nothing in here ever works the developer's device.** The gates read `dumpsys` and `logcat` and -never tap, type or launch — a guessed tap is indistinguishable from a slow screen, and a permission -the tool granted itself is not evidence of anything. +The gates still only read `dumpsys` and `logcat`. The one device actor, `bin/launch-app`, starts the +selected package's launcher activity and stops there: it never taps, types, signs in, or grants a +permission. A permission the tool granted itself would not be evidence of anything. ### Who drives the Google setup stays open until the developer answers @@ -131,6 +131,7 @@ The split is structural rather than conventional. | `bin/agent` | **Reporter.** Runs the verifier and serialises its state; provisions nothing, sends nothing | | `bin/provision` | **Actor.** Idempotent; ends by handing off to `bin/gates` to be judged | | `bin/proof-push` | **Actor.** Sends the one push the ladder exists to prove, and records the marker it sent. Never checks whether it arrived | +| `bin/launch-app` | **Actor.** Opens the selected package's launcher activity on the selected device. Never taps, types, signs in, or grants permission | | `bin/discover` | Read-only list of the developer's Firebase projects and their Android apps | | `bin/iterable-keys` | Walks the four Iterable dashboard steps that have no API, captures the keys, then hands off to `bin/gates`. Offers the identity the app already registered, read from the device | | `bin/handoff` | Prints the block that sends a developer to their own terminal, for an agent to relay verbatim. Changes nothing | diff --git a/bin/config.sh b/bin/config.sh index fbb309d..ce13643 100755 --- a/bin/config.sh +++ b/bin/config.sh @@ -46,7 +46,7 @@ ws_init() { # The entry points a developer is ever told to run. The rest of bin/ is internal — # wizard and provision are dispatched to, never typed. -SHIM_CMDS="onboard teardown discover gates agent iterable-keys proof-push" +SHIM_CMDS="onboard teardown discover gates agent iterable-keys proof-push launch-app" # A host installs a plugin into a version-numbered cache directory, which is both too # long to type and not stable: old version directories stay on disk, so a path a diff --git a/bin/launch-app b/bin/launch-app new file mode 100755 index 0000000..3a652e5 --- /dev/null +++ b/bin/launch-app @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Opens the selected app's launcher activity on the selected Android device. +# +# This is deliberately the whole extent of the device automation: no taps, text, +# key events, sign-in, or permission grants. The person still answers everything +# inside their app; this actor only saves them from finding and opening it after +# an install. + +source "$(dirname "${BASH_SOURCE[0]}")/config.sh" + +[[ -n "$PACKAGE" ]] || { + echo "no package selected yet — choose the Android app before trying to launch it" >&2 + exit 10 +} + +d="$(device_serial)" || { + echo "$d" >&2 + exit 10 +} + +adb -s "$d" shell pm path "$PACKAGE" 2>/dev/null | grep -q '^package:' || { + echo "$PACKAGE is not installed on $d — build and install it before trying to launch it" >&2 + exit 20 +} + +out="$(adb -s "$d" shell am start -W \ + -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + -p "$PACKAGE" 2>&1)" +rc=$? + +if ((rc != 0)) || grep -qiE '(^|[[:space:]])(error|exception):|unable to resolve|does not exist' <<< "$out"; then + echo "could not open $PACKAGE on $d:" >&2 + printf '%s\n' "$out" >&2 + exit 20 +fi + +echo "Opened $PACKAGE on $d." diff --git a/iterable-provision/SKILL.md b/iterable-provision/SKILL.md index d21dfda..8661a7b 100644 --- a/iterable-provision/SKILL.md +++ b/iterable-provision/SKILL.md @@ -214,7 +214,7 @@ relay the block, do not look for a flag that gets you past it, and never set | `project_unreachable` | Relay `next.summary` verbatim — it is a permissions answer from Google, not something to work around — and then ask `next.question`, which offers aiming somewhere else. A typo'd project id and a project they have no access to look identical from here, and both are answered at that screen. | | `register_app` | Their project has no Android app for this package. Ask `next.question` — this is the one change the limits promise never happens unless they ask outright, so its yes is separate from the provisioning yes and recorded with `/bin/agent approve create-app`. Never `CREATE_APP=1`. | | `iterable_keys` | Four steps in their Iterable account that only they can do. Ask `next.question`, then walk them **one at a time** — see *The four Iterable steps* below. The two keys reach the workspace with `iterable-keys --take server\|mobile`, off their clipboard; never into this conversation. | -| `install_app` / `run_app` | The device half — hand off to `iterable-verify`. Until a device is on record `next.question` is the device picker, worth asking even when only one is attached: a phone left plugged in to charge is "the only device", takes the proof push, and the developer watching an emulator reports that nothing arrived. After that, `run_app` keeps a question of its own — signing in and allowing notifications happen inside their app, by their hand. Relay it. Never drive their device over adb. | +| `install_app` / `run_app` | The device half — hand off to `iterable-verify`. Until a device is on record `next.question` is the device picker, worth asking even when only one is attached: a phone left plugged in to charge is "the only device", takes the proof push, and the developer watching an emulator reports that nothing arrived. The verifier may open the selected app after installation; signing in and allowing notifications still happen inside their app, by their hand. | | `send_proof` | The device half — hand off to `iterable-verify`. | | `done` | A real push reached the device. Say which device and chain onward. | diff --git a/iterable-verify/SKILL.md b/iterable-verify/SKILL.md index b085bdf..478ef57 100644 --- a/iterable-verify/SKILL.md +++ b/iterable-verify/SKILL.md @@ -6,8 +6,9 @@ description: >- Use when a push is not arriving, when a developer wants proof the integration works, or right after `iterable-android` / `iterable-react-native` writes the integration — including "why isn't my push arriving?", "is the token registered?", - "prove this works", "test push notifications". Reads only; it never provisions, - never edits the app, and never re-sends to manufacture a pass. + "prove this works", "test push notifications". Its gates read only; it never + provisions, edits the app, or re-sends to manufacture a pass. After installation, + it may open the selected app while leaving every in-app action to the developer. --- # Proving Iterable push on a device @@ -16,19 +17,18 @@ Eighteen checks, of which the last five are the device half. One of them is the that matters — *push arrives on device*: an Iterable push, sent on purpose and carrying a marker, found in the device's own notification records. -**This skill writes nothing and sends nothing on its own.** The verifier does not -trigger the thing it verifies — a checker that sends its own push can only ever -agree with itself. Sending is `/bin/proof-push`, run as a separate, announced step. +**The verification gates write nothing and send nothing.** They do not trigger the +thing they verify — a checker that sends its own push can only ever agree with itself. +Sending is `/bin/proof-push`, run as a separate, announced step. -**And it reads the device without operating it.** `adb shell dumpsys`, `logcat`, `pm -path` — yes. `adb shell input` (tap, text, keyevent), `am start`, `monkey`, `pm grant`, -or any other way of working their app for them — no, at any point, including when it -would be quicker. Three reasons, and each is sufficient: you cannot see their login -screen, so every field is a guess and a wrong guess looks exactly like a slow screen; -it costs minutes per attempt and often does not work; and the notification permission -is a question Android asks *the person*, so a tool that taps Allow has answered it on -their behalf and then reports the answer as evidence. When the device needs a human, -the tool says so in a question — relay it and wait. +**The gates read the device without operating it.** `adb shell dumpsys`, `logcat`, `pm +path` — yes. One separate actor, `/bin/launch-app`, may start the selected +package's launcher activity after installation. That is the boundary: `adb shell +input` (tap, text, keyevent), `monkey`, `pm grant`, or any other way of working their +app for them — no, at any point. You cannot see their login screen, so every field is +a guess; and the notification permission is a question Android asks *the person*, so +a tool that taps Allow has answered it on their behalf and then reports the answer as +evidence. Open the app, relay the human question, and wait. ## Finding the scripts @@ -81,8 +81,8 @@ that is still wrong. | `next.kind` | What you do | |---|---| -| `install_app` | Their app is not on the device, or the installed APK does not carry the SDK. Build and install, then re-run. The check reads `dumpsys package` for the Iterable messaging service, so it catches "integrated the SDK, forgot to reinstall" — invisible to anything that only reads the repo. | -| `run_app` | Their hands, not yours. Relay `next.question` — it asks them to sign in as `target.email` and allow notifications, and one of its options re-runs the checks. Do not launch the app, drive the UI, or answer the permission dialog (see below). | +| `install_app` | Their app is not on the device, or the installed APK does not carry the SDK. Build and install it, run `/bin/launch-app`, then re-run the agent. The check reads `dumpsys package` for the Iterable messaging service, so it catches "integrated the SDK, forgot to reinstall" — invisible to anything that only reads the repo. | +| `run_app` | Run `/bin/launch-app` first, then relay `next.question`. It asks them to sign in as `target.email` and allow notifications, and one option re-runs the checks. Opening the launcher activity is yours; driving the UI or answering the permission dialog is never yours. | | `send_proof` | Announce it, then run `/bin/proof-push`. It sends one marked push and deliberately does not check whether it arrived. Then re-run `/bin/agent`. | | `campaign_send` | Server-side corroboration needs `ITBL_CAMPAIGN_ID`; without it there is nothing to read. Optional. | | `iterable_keys`, `provision`, `choose_target`, … | Not this skill's half — hand back to `iterable-provision`. | diff --git a/tests/launch-app.sh b/tests/launch-app.sh new file mode 100755 index 0000000..1031dd2 --- /dev/null +++ b/tests/launch-app.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Pins the one allowed device action: open the chosen package's launcher activity. +# Everything after that — sign-in and the notification prompt — remains human work. + +set -uo pipefail +cd "$(dirname "$0")/.." + +FAILED=0 +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAILED=1; } + +STUB="$(mktemp -d)" +trap 'rm -rf "$STUB"' EXIT +mkdir -p "$STUB/ws" + +cat > "$STUB/adb" <<'ADB' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$STUB_CALLS" + +if [[ "$1" == devices ]]; then + printf 'List of devices attached\nemulator-5554\tdevice\n' + exit 0 +fi + +[[ "$1 $2" == "-s emulator-5554" ]] || exit 1 +shift 2 +case "$*" in + "emu avd name") echo Pixel_9_Pro ;; + "shell pm path com.example.app") + [[ "${STUB_INSTALLED:-1}" == 1 ]] && echo package:/data/app/com.example.app/base.apk + ;; + "shell am start -W -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -p com.example.app") + if [[ "${STUB_LAUNCHABLE:-1}" == 1 ]]; then + printf 'Starting: Intent\nStatus: ok\n' + else + echo 'Error: Activity not started, unable to resolve Intent' >&2 + exit 1 + fi + ;; +esac +ADB +chmod +x "$STUB/adb" + +run_launch() { + PATH="$STUB:$PATH" WS="$STUB/ws" PACKAGE="${PACKAGE_UNDER_TEST-com.example.app}" \ + TARGET_DEVICE=Pixel_9_Pro STUB_CALLS="$STUB/calls" \ + STUB_INSTALLED="${STUB_INSTALLED:-1}" STUB_LAUNCHABLE="${STUB_LAUNCHABLE:-1}" \ + bin/launch-app 2>&1 +} + +echo +echo " Opening the installed app" +echo + +: > "$STUB/calls" +out="$(run_launch)"; rc=$? +if ((rc == 0)) && [[ "$out" == *"Opened com.example.app on emulator-5554."* ]]; then + ok "opens the selected package on the selected device" +else + bad "expected a successful launch, got rc $rc: $out" +fi + +expected="shell am start -W -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -p com.example.app" +grep -qF "$expected" "$STUB/calls" \ + && ok "uses the package's MAIN/LAUNCHER activity" \ + || bad "did not issue the expected launch intent" + +if grep -qE 'shell (input|monkey)|pm grant|keyevent| tap | text ' "$STUB/calls"; then + bad "used device input or granted a permission" +else + ok "does not tap, type, sign in, or grant permission" +fi + +echo +echo " Refusing states that cannot be launched" +echo + +: > "$STUB/calls" +STUB_INSTALLED=0 out="$(run_launch)"; rc=$? +if ((rc == 20)) && [[ "$out" == *"is not installed"* ]] \ + && ! grep -q 'shell am start' "$STUB/calls"; then + ok "does not try to open an app that is not installed" +else + bad "uninstalled app should stop before launch, got rc $rc: $out" +fi + +: > "$STUB/calls" +PACKAGE_UNDER_TEST="" out="$(run_launch)"; rc=$? +if ((rc == 10)) && [[ "$out" == *"no package selected"* ]] \ + && ! grep -q 'shell am start' "$STUB/calls"; then + ok "does not guess which package to open" +else + bad "missing package should ask for a choice, got rc $rc: $out" +fi + +: > "$STUB/calls" +unset STUB_INSTALLED +unset PACKAGE_UNDER_TEST +STUB_LAUNCHABLE=0 out="$(run_launch)"; rc=$? +if ((rc == 20)) && [[ "$out" == *"could not open"* ]]; then + ok "reports a package with no launchable activity" +else + bad "unlaunchable package should fail clearly, got rc $rc: $out" +fi + +echo +((FAILED)) && { echo " FAILED"; exit 1; } +echo " All good — launch stops where human interaction begins." diff --git a/tests/no-bare-commands.sh b/tests/no-bare-commands.sh index d5dd9d0..426d415 100755 --- a/tests/no-bare-commands.sh +++ b/tests/no-bare-commands.sh @@ -104,7 +104,7 @@ SW="$(mktemp -d)/proj"; mkdir -p "$SW" ( cd "$SW" && WS="$SW/.iterable" bash -c "source '$ROOT/bin/config.sh'; ws_init" ) >/dev/null 2>&1 missing="" -for c in onboard teardown discover gates agent iterable-keys proof-push; do +for c in onboard teardown discover gates agent iterable-keys proof-push launch-app; do [[ -x "$SW/.iterable/$c" ]] || missing="$missing $c" done [[ -z "$missing" ]] && ok "every entry point a developer is told to run gets one" \