open-standards/wai/wai-sink-android/README.md
Transaction Science cc18490979
Some checks failed
WAI operation catalogue / does this change touch it (push) Successful in 17s
wai-sink-android native sink library / does this change touch it (push) Successful in 18s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 6m34s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 9s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 48s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 6m36s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 7m22s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Has been cancelled
wai release: the Maven publication and the images wait for the owner's setup
The first wai-v* tag after 0833173ed would have failed the Android leg's publication and the
images job without the secret WAI_PACKAGES_TOKEN, and left the images job unclaimed with no
runner labelled oci, which keeps the run open. Both now run only once the setup exists, and fail
as before once they run:

- A job setup (gate pool, no checkout) reports whether WAI_PACKAGES_TOKEN is set as an output,
  never its value, since a job's `if:` cannot read a secret; each missing piece of the setup is
  one notice line, and it succeeds.
- The Maven publication step tests the mapped secret: without it, one notice and success. The
  AAR is still built, checked and attached.
- The images job needs version and setup, with
  `if: needs.version.result == 'success' && needs.setup.outputs.packages == 'true' &&
  vars.WAI_OCI_RUNNER == 'true'`. Forgejo 16.0.6 evaluates a blocked job's `if:` on the server
  once its needs are done (services/actions/job_emitter.go, prepareJobForEmitting), with the
  `vars` context and the needs' results and outputs (runner v12.13.2's jobparser,
  newJobIfInterpreter); a false one marks the job skipped, so it is never queued for a runner.
  Only `env` and `secrets` there fall back to the runner, and neither is used.

The workflow's header gives the owner's steps in order: the secret, then an oci runner and the
variable WAI_OCI_RUNNER=true.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:35:05 -05:00

8.6 KiB

wai-sink-android — the native sink for the platform player, in Kotlin

The native sink of extensions/native-sink.md for phones, tablets and televisions whose applications are written in Kotlin, as a library (an AAR). The platform's media player library plays the stream and decodes on the platform's decoders, unchanged; the sink sits on the path of its requests and, for one playback session:

  • verifies a media playlist against its playlist receipt and the chain of its live revisions (http-delivery §8.6), and every object whose name states its content hash against that hash, and withholds from the player whatever does not verify;
  • signs, under a fresh key per session, stream-audit's acknowledgements of every object received, a presentation claim for each marked ad, and the session record, and posts the bundle to the owner's collector (wai-audit serve), with its Event-mode CMCD while it plays;
  • ends a session nobody can see, or whose viewer has gone idle, and stops the player's requests.

Everything that decides is libwai's C interface (native-sink §9), the same on every native platform; this library is the platform's half: the JNI layer over that interface, the loopback relay, the player session, and the signals of who can see it. The records it signs are the ones the page's integration and the Swift package sign, byte for byte (the corpus's page-parity case).

Layout

path what
jni/wai_sink_jni.c the JNI layer: one function per call of wai.h, statuses carried as WaiSinkException
core/src/main/kotlin the platform-free half: WaiSink (one session, every call, JSON in and out), WaiRelay (the loopback server the player is given, native-sink §4.4), WaiSessionDriver (the observation loop, §5, §6), WaiCollector (v1/sessions, v1/cmcd), Json
sink/src/main/kotlin the platform's half: WaiPlayerSession (the media player library through the relay, observed every 200 ms) and WaiViewAttention (the activity's lifecycle, the window's and the display's state, the view's place in its window, the window's input, §6.2)
core/src/test, core/src/testkit the unit tests on the host JVM, through the JNI layer over a host build of libwai; the kit they share with the device tests
sink/src/androidTest the instrumented tests: the corpus and the relay on the device, the player end to end, the attention signals in a real activity
scripts/build-native.sh libwai and the JNI layer: android for the AAR's ABIs, host for the JVM tests
scripts/gradle.sh the pinned Gradle, fetched once and checked against its SHA-256
scripts/test-emulator.sh the instrumented tests in an emulator, headless
scripts/check-publication.sh the publication maven-publish writes, held to its coordinates and contents before a release publishes it

Use

val config = WaiSink.Config(channel = "news-east", owner = "<the collector's key, 64 hex>")
val session = WaiPlayerSession(context, "https://media.example/ch/7/v.m3u8", config,
    attention = WaiViewAttention(playerView),
    collector = WaiCollector("https://collector.example/"))
playerView.player = session.player     // the host shows the player as it always has
session.onEnd = { bundle, ended -> /* posted already; kept here if the host wants it */ }
session.start()

Create, start and stop the session on the main thread. The player reads the relay over HTTP on the loopback interface, so the application's network security configuration permits cleartext to 127.0.0.1 and nowhere else:

<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">127.0.0.1</domain>
    </domain-config>
</network-security-config>

The relay listens on 127.0.0.1 alone, on a port the platform assigns, and answers only paths under its session's random 128-bit token; anything the sink refuses is answered 502, and the bytes go nowhere. For a stream the owner's packager seals (playlist receipts, segments named by their SHA-256, packaging §7), set requireSigned = true (or contentAddressed = true), and nothing the publisher did not sign reaches the decoder. For a stream another service delivers, leave both off: it is acknowledged and evidenced, never refused for what its names look like.

WaiViewAttention reads shown as: the view's activity started (a split screen or a picture-in-picture window counts as shown), its window visible and the display interactive; in view as the share of the view inside its window's visible frame. The platform reports no occlusion of one application's window by another's, so where windows overlap this states the window's place on the screen, not what covers it (native-sink §6.2). A host with its own notion of visibility gives a WaiAttention of its own.

Build and test

Versions, pinned: NDK 29.0.14206865, Android Gradle Plugin 8.13.0, Gradle 8.13 (checked by SHA-256), Kotlin 2.3.21, JDK 17; compileSdk 36, minSdk 24; the media player library 1.11.1; the Rust toolchain of rust-toolchain.toml with the targets aarch64-linux-android, armv7-linux-androideabi and x86_64-linux-android. ABIs: arm64-v8a, armeabi-v7a, x86_64, each library linked for 16 KiB pages.

cd wai/wai-sink-android
scripts/build-native.sh host                              # libwai and the JNI layer for this machine
scripts/gradle.sh -Pwai.sink.jvmOnly=true :core:test      # the unit tests (no Android SDK needed)
scripts/build-native.sh android                           # every ABI, with the NDK
scripts/gradle.sh :sink:assembleRelease                   # sink/build/outputs/aar/sink-release.aar
scripts/test-emulator.sh                                  # the instrumented tests, headless

The unit tests run the corpus live-conformance/native-sink/ through the JNI layer, every case and every refused configuration, and require the page-parity bundle byte for byte; run the relay over the reference package's floor (presented byte for byte; a fragment changed at the origin answered 502 with no bytes on every request; ranges served from what verified; content codings removed; an unsigned playlist refused where signing is required; nothing answered outside the session's token); and drive the observation loop to its hidden and idle endings with a clock they advance. The instrumented tests run the same corpus and relay checks on the device, play the floor (AV1 in CMAF) through the platform's media player library and the relay to its end with a video surface attached, refuse the changed fragment on every retry the player makes, end a session nobody can see as hidden with its player stopped, and read the attention signals from a real activity.

The emulator runs the host's ABI: on an arm64 host the instrumented tests exercise the arm64-v8a libraries; the armeabi-v7a and x86_64 libraries are built, linked and checked, not run.

Distribution

A release builds every ABI and publishes the library with maven-publish as science.transaction.wai:wai-sink:<version>, the version of wai/wai-rs: attached to the release as wai-sink-<version>.aar, and to the forge's Maven registry once the repository holds the token the release publishes with (.github/workflows/wai-release.yml). The library's manifest is of the package science.transaction.wai.sink; its classes keep the package wai.sink, which the JNI layer's symbols name. From the registry, with its POM, it brings the media player library and its HTTP presentations module at the version sink/build.gradle.kts names:

repositories { maven("https://git.transaction.science/api/packages/Transaction-Science/maven") }
dependencies { implementation("science.transaction.wai:wai-sink:<version>") }

Used as a file, the AAR brings no dependency of its own, and an application adds those two itself. Before either, the release writes the publication to a repository on disk and holds it to scripts/check-publication.sh: its coordinates, its manifest's package, both libraries for each ABI, and its classes.

scripts/gradle.sh :sink:publishReleasePublicationToStagingRepository   # sink/build/staging-repo
scripts/check-publication.sh sink/build/staging-repo <version>

The library holds no codec: the platform's player decodes, with the platform's codecs.

Energy

The platform's player decodes in a process of the platform's, which no counter an application can read covers. The session therefore states the sink decode and the display as the unmetered marker (native-sink §7), never the application's own counter as either. An emulator's figures are the host machine's, not a device's, and none is stated.