Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 100 additions & 7 deletions .github/workflows/android-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
# channel, so a separate job would rebuild the APK and reboot the AVD to assert the same tree.
# - visual (adb): the pixel VRT against the CI-recorded baseline. A pixel baseline is host-specific,
# so — like ios-e2e.yml's `visual` and web-e2e.yml's lanes — it is a non-gating signal.
# - network (adb): the BE-0283 network-capture assertion — a `request` observes real emulator
# traffic reported through BajutsuAndroid's interceptor over the `adb reverse` collector bridge.
# - conformance (adb): the BE-0114 driver conformance contract (tests/driver_conformance.py) run
# on-device against the adb driver (BE-0270) — the Android twin of ios-e2e.yml's `conformance
# (idb + xcuitest)`. It proves the determinism-core invariants (an ambiguous selector fails, a
Expand All @@ -23,7 +25,7 @@
# It runs no LLM — a deterministic `run` over fixed scenarios — so it stays within the prime
# directives, and it is kept off the fast `make check` gate exactly as the idb and web e2e lanes are.
# The lane now carries a REQUIRED status check like iOS's `E2E`: the `E2E (android)` job below
# `needs` the deterministic host-independent jobs (`smoke`, `conformance`) and always reports (a
# `needs` the deterministic host-independent jobs (`smoke`, `conformance`, `network`) and always reports (a
# path-skip is a pass), so it — not the KVM jobs, which are path-gated — is what `main`'s
# branch-protection ruleset pins. `golden` and `visual` are deliberately NOT part of its `needs:`: a
# pixel baseline is host-specific (the x86_64 software renderer varies) and the element-tree golden
Expand Down Expand Up @@ -293,6 +295,93 @@ jobs:
path: runs/
if-no-files-found: ignore

# On-device network capture (BE-0283): a `request` assertion observes real emulator traffic end to
# end. The showcase GETs SHOWCASE_API_URL/horses over OkHttp, BajutsuAndroid's interceptor reports
# the exchange to the host collector, and the runner bridges that collector to the emulator with
# `adb reverse` (the transport this item delivers). `e2e-network` runs a throwaway host stub the
# emulator reaches at 10.0.2.2 and points SHOWCASE_API_URL there. Reads are minimal (wait Refresh,
# tap, wait status), so — like the visual job — the resident server is not built (dump suffices).
network:
name: network (adb)
Comment thread
0x0c marked this conversation as resolved.
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
Comment thread
0x0c marked this conversation as resolved.
# A cold AVD boot + the Compose build + one network scenario is ~12min; 25 catches a real hang.
timeout-minutes: 25
env:
UV_NO_DEV: "1"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true

- name: Set up JDK 17
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: temurin
java-version: '17'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Install the base package (no dev)
run: uv sync --no-dev

# The scenario drives the Compose a11y twin; bajutsu's launch installs it from the config's appPath.
- name: Build the Compose showcase APK
run: make -C demos/showcase/android compose-build

- name: Cache AVD
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-api34-x86_64-pixel6

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 34
arch: x86_64
target: google_apis
profile: pixel_6
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
disable-animations: false
script: echo "Generated AVD snapshot for caching."

- name: Run the network-capture scenario (adb)
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 34
arch: x86_64
target: google_apis
profile: pixel_6
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
disable-animations: true
script: make -C demos/showcase/android e2e-network

- name: Upload run artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-e2e-network-run
path: runs/
if-no-files-found: ignore

# The driver conformance suite (BE-0114): one backend-agnostic contract, run on-device against the
# adb driver (BE-0270) — the Android twin of ios-e2e.yml's `conformance (idb + xcuitest)`. It proves
# the determinism-core invariants (ambiguous selector fails, zero-match fails, capabilities() matches
Expand Down Expand Up @@ -483,9 +572,12 @@ jobs:
# Single required status check for the Android lane — the Android twin of ios-e2e.yml's `E2E`.
# Always runs so it reports even when the KVM jobs are skipped (a PR that can't affect the adb
# path) — a skipped dependency is a pass; only a real failure or cancellation fails the gate.
# `golden` and `visual` are deliberately excluded from `needs:` (see the header): the element-tree
# golden can drift with an upstream dependency and a pixel baseline is host-specific, so each stays
# a per-PR signal. Make `E2E (android)` the required check, not the path-gated KVM jobs.
# `network` joins `smoke`/`conformance` in `needs:`: its pass/fail is a deterministic,
# host-independent fact (was the exchange observed?), so a regression there must fail the required
# check like the other two — it is path-gated the same way, so a skip is still a pass. `golden` and
# `visual` remain deliberately excluded (see the header): the element-tree golden can drift with an
# upstream dependency and a pixel baseline is host-specific, so each stays a per-PR signal. Make
# `E2E (android)` the required check, not the path-gated KVM jobs.
#
# `changes`'s own result is checked too, not just the KVM jobs': `changes` has no `if:`, so it
# always runs and never reports `skipped` — but if it *fails* (e.g. a misconfigured `E2E_LANE`,
Expand All @@ -494,7 +586,7 @@ jobs:
# them would read that as a pass — the required gate reporting green having run nothing.
e2e:
name: E2E (android)
needs: [changes, smoke, conformance]
needs: [changes, smoke, conformance, network]
if: always()
runs-on: ubuntu-latest
steps:
Expand All @@ -503,9 +595,10 @@ jobs:
CHANGES: ${{ needs.changes.result }}
SMOKE: ${{ needs.smoke.result }}
CONFORMANCE: ${{ needs.conformance.result }}
NETWORK: ${{ needs.network.result }}
run: |
echo "changes=$CHANGES smoke=$SMOKE conformance=$CONFORMANCE"
for result in "$CHANGES" "$SMOKE" "$CONFORMANCE"; do
echo "changes=$CHANGES smoke=$SMOKE conformance=$CONFORMANCE network=$NETWORK"
for result in "$CHANGES" "$SMOKE" "$CONFORMANCE" "$NETWORK"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
echo "E2E (android) failed"
exit 1
Expand Down
47 changes: 45 additions & 2 deletions BajutsuAndroid/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

Android 上の [bajutsu](../) 向けのアプリ内デバイス支援ライブラリです。プラットフォームがアプリ
プロセスの内側からしか公開しない機能を bajutsu が駆動できるようにする、test/debug 専用の Android
ライブラリです。現在は**クリップボード**を担い、iOS の [`BajutsuKit`](../BajutsuKit) パッケージに
対応する Android 版にあたります。
ライブラリです。**クリップボード**と**ネットワーク捕捉**を担い、iOS の [`BajutsuKit`](../BajutsuKit)
パッケージに対応する Android 版にあたります。

## アプリ内支援が要る理由

Expand Down Expand Up @@ -75,7 +75,50 @@ project(":bajutsu-android").projectDir = file("../../../BajutsuAndroid")
**test/debug ビルド専用**です。`startClipboard` の呼び出しを `BuildConfig.DEBUG`(または独自の
テストフラグ)で囲い、リリースビルドで動かないようにしてください。

## ネットワーク捕捉(BE-0283)

`request` / `requestSequence` のアサーションは、iOS と同じアプリ内コレクタのモデルで通信を観測します。
ネットワーク捕捉を伴うシナリオを走らせるとき、bajutsu はホストの `127.0.0.1:<port>` にコレクタを立て、
`adb reverse` でエミュレータへ橋渡しし、その URL を `BAJUTSU_COLLECTOR` の intent extra として注入します。
iOS の `URLProtocol` があらゆる `URLSession` に透過的に割り込むのに対し、Android には全クライアントに届く
単一の OS レベルの HTTP フックがありません。そこで対象アプリは、自分の OkHttp クライアントに一行を足し、
起動時に一度だけ報告を有効化します。

```kotlin
import dev.bajutsu.android.BajutsuNet

// 起動時に一度、すでに読んでいる launch env のマップから(bajutsu がコレクタを注入していなければ
// 何もしないので、test/debug ビルドでは無条件に呼んでも安全です):
BajutsuNet.configure(launchEnv)

// 対象アプリが組み立てる OkHttpClient に:
val client = OkHttpClient.Builder()
.addInterceptor(BajutsuNet.interceptor())
.build()
```

インターセプタは `configure` が `BAJUTSU_COLLECTOR` を見つけるまで不活性です。有効になると、完了した
やり取りごとに bajutsu の `NetworkExchange` に一致する JSON をコレクタへ POST します。報告自体が
インターセプトされないよう、送信には別のクライアントを使います。OkHttp はここでは `compileOnly` の依存
なので、バージョンは対象アプリが持ち込みます。
Comment thread
0x0c marked this conversation as resolved.

**テスト/デバッグ専用です。** インターセプタはヘッダとボディを記録します。`configure` の呼び出しは
(上記のように)デバッグフラグで守り、bajutsu の `redact` を設定して、`network.json` に書き出す証跡
から機密情報を隠してください。iOS 向けに [BajutsuKit の Safety note](../BajutsuKit/README.md#safety)
が述べているのと同じ注意点です。

**クリアテキストの例外設定が必要です。** `BAJUTSU_COLLECTOR` は平文 HTTP の `127.0.0.1` URL であり、
Android(API 28 以降)は既定でクリアテキスト通信を遮断します(loopback を App Transport Security
(ATS)で除外する iOS とは異なります)。テスト/デバッグビルドに `127.0.0.1` へのクリアテキスト例外を `network_security_config`
で追加してください(設定例は `demos/showcase/android/*/src/main/res/xml/network_security_config.xml`)。
これがないと、インターセプタの報告 POST は `CLEARTEXT communication to 127.0.0.1 not permitted` で
失敗し、ログには残るものの他に痕跡はなく、やり取りがコレクタに届きません。

## 対応範囲

アプリのプライマリクリップに対する set / get / clear です。`get` は先頭のクリップ項目をテキストに
変換して読みます。テキスト以外のクリップ内容(画像、intent)は対象外です。

ネットワーク捕捉が見るのは **OkHttp 由来の HTTP(S)** だけで、iOS の `URLSession` に限られた捕捉と同じ
範囲です。`HttpURLConnection` を直接使う通信、別の HTTP クライアント、`WebView` は対象外です(`WebView`
は iOS と同じく、別途の追随が要ります)。
49 changes: 47 additions & 2 deletions BajutsuAndroid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
English · [日本語](README.ja.md)

In-app device support for [bajutsu](../) on Android. A test/debug-only Android library that lets
bajutsu drive capabilities the platform only exposes from inside the app process. Today it backs the
**clipboard**; it is the Android peer of the iOS [`BajutsuKit`](../BajutsuKit) package.
bajutsu drive capabilities the platform only exposes from inside the app process. It backs the
**clipboard** and **network capture**; it is the Android peer of the iOS [`BajutsuKit`](../BajutsuKit)
package.

## Why in-app support

Expand Down Expand Up @@ -73,7 +74,51 @@ installed on the device — can send the broadcast and read or write the clipboa
only.** Gate the `startClipboard` call behind `BuildConfig.DEBUG` (or your own test flag) so it never
runs in a release build.

## Network capture (BE-0283)

`request` / `requestSequence` assertions observe traffic through the same in-process collector model
as iOS. When bajutsu runs a scenario with network capture it starts a collector on the host's
`127.0.0.1:<port>`, bridges it to the emulator with `adb reverse`, and injects its URL as the
`BAJUTSU_COLLECTOR` intent extra. Unlike iOS's `URLProtocol`, which swizzles into every `URLSession`
transparently, Android has no single OS-level HTTP hook, so the app adds one line to its OkHttp client
and enables reporting once at launch:

```kotlin
import dev.bajutsu.android.BajutsuNet

// Once at launch, from the same launch-env map you already read (a no-op unless bajutsu injected a
// collector, so it is safe to call unconditionally in a test/debug build):
BajutsuNet.configure(launchEnv)

// On the OkHttpClient the app under test builds:
val client = OkHttpClient.Builder()
.addInterceptor(BajutsuNet.interceptor())
.build()
```

The interceptor is inert until `configure` finds `BAJUTSU_COLLECTOR`; when enabled it POSTs each
completed exchange to the collector as JSON matching bajutsu's `NetworkExchange`, over a separate
client so the report is never itself intercepted. OkHttp is a `compileOnly` dependency here — the app
brings its own version.
Comment thread
0x0c marked this conversation as resolved.

**Test/debug only.** The interceptor records headers and bodies; gate the `configure` call behind a
debug flag (as shown above) and configure bajutsu's `redact` to mask secrets in the written evidence
(`network.json`) — the same caveat [BajutsuKit's Safety note](../BajutsuKit/README.md#safety) states
for iOS.
Comment thread
0x0c marked this conversation as resolved.

**Cleartext exception required.** `BAJUTSU_COLLECTOR` is a plain-HTTP `127.0.0.1` URL, and Android
(API 28+) blocks cleartext traffic by default — unlike iOS, which exempts loopback from App Transport
Security (ATS). Add a
`network_security_config` cleartext exception for `127.0.0.1` to your test/debug build (see
`demos/showcase/android/*/src/main/res/xml/network_security_config.xml` for the pattern), or the
interceptor's report POST fails with `CLEARTEXT communication to 127.0.0.1 not permitted` — logged but
otherwise silent, so no exchange ever reaches the collector.

## Coverage

Clipboard set / get / clear on the app's primary clip. `get` reads the first clip item coerced to
text. Non-text clip content (images, intents) is out of scope.

Network capture sees **OkHttp-originated HTTP(S)** only — the bounded scope iOS's `URLSession`-only
capture has. Traffic through `HttpURLConnection` directly, a different HTTP client, or a `WebView` is
out of scope (a `WebView` needs its own follow-up, as it did on iOS).
5 changes: 5 additions & 0 deletions BajutsuAndroid/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ android {
dependencies {
// ContextCompat.registerReceiver picks the right RECEIVER_EXPORTED handling across API levels.
implementation("androidx.core:core-ktx:1.13.1")
// BajutsuNet's interceptor exposes OkHttp types (BE-0283), but only apps that already use OkHttp
// call it, so they bring the runtime dependency — compileOnly keeps the library from pinning a
// version onto its consumers. okio is OkHttp's transitive I/O layer, used for the request-body copy.
compileOnly("com.squareup.okhttp3:okhttp:4.12.0")
compileOnly("com.squareup.okio:okio:3.9.0")
}
Loading
Loading