All checks were successful
WAI operation catalogue / does this change touch it (push) Successful in 12s
wai-sink-android native sink library / does this change touch it (push) Successful in 13s
wai-sink-swift native sink package / does this change touch it (push) Successful in 27s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 14s
WAI examples / WAI examples · does this change touch them (push) Successful in 16s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 33s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 13m46s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 9m59s
WAI examples / WAI examples · portable · linux (push) Successful in 12m29s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 7m25s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 7m30s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 11m13s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 16m30s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (push) Successful in 34m15s
WAI examples / WAI examples · android-app · linux-arm64 (push) Successful in 21m46s
The Kotlin library carries the decoder the Swift package carries. jni/wai_sink_jni.c gains the
thirteen wai_int_* calls (decoder open, free and info; a parameter set's new, add, listing and
free; a payload's decode and an envelope's, answering { bytes, answer }; a frame stream's open,
reset and free and its next object's decode); WaiIntDecoder and WaiIntFrameStream wrap them as
the Swift package does, and WaiSinkException.INT_INPUT carries WAI_ERR_INT_INPUT.
scripts/build-native.sh builds libwai with sink_decode for every ABI (wgpu's Vulkan backend,
loaded at run time; nothing new beyond the crates wai-rs's Cargo.lock holds) and checks the new
exports; its macOS host build now reads a Mach-O library's exports with nm -g (-D read none).
core/src/testkit/int-cases.json names a committed vector of each of the eight integer
capabilities with its set and the output its corpus commits (a SHA-256 of the whole output or of
each frame, or photo's ref_rgb.bin), and a stream of each of the three frame forms from the
corpus's own index. IntDecodeTest (host JVM) and IntDecodeDeviceTest (instrumented;
test-emulator.sh stages the files into the test APK) decode every case and every frame with the
GPU offered and on the CPU alone, requiring the same bytes and the committed output; a float
capability is refused on both integer paths; an unreadable configuration is INT_INPUT.
Run on 2026-11-11: on an arm64 laptop (Metal), the host JVM tests, 20 of 20: 8 of 9 cases on the
GPU (wai.video.int_motion on the CPU) and 7 of 16 frames, every output the CPU's and the committed
one; the three ABIs built and linked (16 KiB pages, exports checked); in the arm64 emulator (API
36), 16 of 16 instrumented tests. The emulator's own Vulkan driver (gfxstream over the host's
software renderer) crashed the test process with a SIGSEGV inside its vkQueueSubmit at the
decoder's first submission, so test-emulator.sh runs it with the guest's Vulkan off
(WAI_SINK_EMULATOR_VULKAN=on keeps it); the decoder there states the GPU absent and decodes every
case on the CPU. No run on a device's Vulkan GPU was possible here: the README and native-sink
§13 say so.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
216 lines
14 KiB
Markdown
216 lines
14 KiB
Markdown
# wai-sink-android — the native sink for the platform player, in Kotlin
|
||
|
||
The native sink of [`extensions/native-sink.md`](../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`), `WaiIntDecoder` (WAI's integer capabilities decoded in the application's process, §13), `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, and the integer decode's cases (`int-cases.json`) |
|
||
| `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 |
|
||
| `tv/` | a television host for the sink: one activity, a launcher entry for the television launcher, a banner (*Television*) |
|
||
| `scripts/check-tv-apk.sh`, `scripts/tv-artwork.py` | the television host's compiled manifest held to the platform's television requirements; its banner and icon, drawn |
|
||
| `scripts/check-publication.sh` | the publication maven-publish writes, held to its coordinates and contents before a release publishes it |
|
||
|
||
## Use
|
||
|
||
```kotlin
|
||
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:
|
||
|
||
```xml
|
||
<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);
|
||
drive the observation loop to its `hidden` and `idle` endings with a clock they advance; and decode
|
||
the integer decode's cases (`core/src/testkit/int-cases.json`: a committed vector of each of the
|
||
eight integer capabilities, with its set, and a stream of each of the three frame forms, frame by
|
||
frame) through `WaiIntDecoder` with the GPU offered and on the
|
||
CPU alone, requiring the same bytes, each the output its corpus commits (its SHA-256, each frame's,
|
||
or the bytes themselves), and an answer that names
|
||
the unit that ran it. The instrumented tests run the same corpus, relay and integer decode checks on
|
||
the device (`scripts/test-emulator.sh` stages the cases' files into the test APK), 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.
|
||
|
||
## Television
|
||
|
||
The library runs on a television as on a phone: nothing in it needs a touchscreen, and its
|
||
manifest declares `android.hardware.touchscreen` and `android.software.leanback` not required,
|
||
so an application that merges it is not made to need either (a `required="true"` elsewhere still
|
||
wins the merge). The attention signals count a key from a remote control as the viewer's input,
|
||
as they count a touch.
|
||
|
||
`tv/` is a television host for the sink, written from the platform's public documentation for
|
||
television applications (read 2026-11-11): one activity with a launcher entry for the television
|
||
launcher (`CATEGORY_LEANBACK_LAUNCHER`, beside the ordinary one), a 320×180 banner with the
|
||
application's name in it and a 160×160 icon (`scripts/tv-artwork.py` draws both, byte for byte
|
||
the same on every host; an owner replaces them), touchscreen and leanback declared not required,
|
||
and every control a focusable view with its focus drawn as a thick outline. With the programme
|
||
in focus the centre key pauses and resumes and left and right seek ten seconds; the media keys
|
||
play, pause, stop, rewind and fast-forward; Back leaves for the home screen. What it plays comes
|
||
in the launching intent:
|
||
|
||
```
|
||
adb shell am start -n science.transaction.wai.tv/wai.sink.tv.TvActivity \
|
||
--es url https://media.example/ch/7/v.m3u8 --es channel news-east --es owner <64 hex>
|
||
```
|
||
|
||
```
|
||
scripts/gradle.sh :tv:assembleDebug # tv/build/outputs/apk/debug/tv-debug.apk
|
||
scripts/check-tv-apk.sh tv/build/outputs/apk/debug/tv-debug.apk # its manifest, read with aapt2
|
||
```
|
||
|
||
CI builds it and reads its manifest. **It has not run on a television.** The emulator here has a
|
||
phone image only; a television image is a further SDK component with its own licence, which was
|
||
not accepted. With one, an owner makes a television virtual device, installs the debug APK with
|
||
`adb install`, and launches it as above; for distribution, the APK is signed with the owner's
|
||
key and published through the owner's store account.
|
||
|
||
## 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:
|
||
|
||
```kotlin
|
||
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.
|
||
|
||
## WAI's integer capabilities, decoded in the application's process
|
||
|
||
`WaiIntDecoder` is native-sink §13's decoder (`libwai`'s `sink_decode`, through `wai_int_*`), the
|
||
one the Swift package carries: one per process, it finds the device's GPU and runs the start-up
|
||
exactness check on it when it opens, then decodes whole payloads and `WAI1` envelopes of the eight
|
||
integer capabilities, each answer naming the unit that ran it.
|
||
|
||
```kotlin
|
||
val decoder = WaiIntDecoder.open() // WaiIntDecoder.Gpu.OFF: the CPU alone
|
||
val gpu = decoder.gpu() // state, adapter, translator, why
|
||
val out = decoder.decode("wai.neural.int_hyper", payload,
|
||
mapOf("entropy.json" to entropy, "g_s.whm" to gS, "h_s.whm" to hS))
|
||
out.bytes; out.media; out.unit; out.dispatch // RGB8 rows; what they are; gpu or cpu
|
||
decoder.close()
|
||
```
|
||
|
||
A stream of an integer frame form (`wai.video.int_motion.frame`, `wai.video.int_hyper.frame`,
|
||
`wai.video.int_cond.frame`; native-sink §13.6) is a `WaiIntFrameStream`, opened with its set (and
|
||
the set's listing, for a pin in the form `wai.prior.set`); each object received goes to
|
||
`decoder.decodeFrame(envelope, stream)` in order, and the answer's `frame` names the frame admitted.
|
||
|
||
`libwai` is built with `sink_decode` for every ABI. Its GPU path is wgpu's Vulkan backend, which
|
||
loads the platform's Vulkan library at run time: a device with no Vulkan, or whose GPU does not
|
||
pass the check, decodes on the CPU, and every answer says so (`dispatch_unit_absent`,
|
||
`dispatch_unit_inexact`). The same bytes come out either way.
|
||
|
||
**It has not run on a device's Vulkan GPU.** The emulator here has no GPU of its own: its Vulkan
|
||
driver forwards to the host's software renderer, and on 2026-11-11 (emulator 36.3.10, image
|
||
android-36.1, arm64) that driver crashed the test process with a SIGSEGV inside its own
|
||
`vkQueueSubmit` at the decoder's first submission, during discovery, which no process survives. So
|
||
`scripts/test-emulator.sh` runs the emulator with the guest's Vulkan off
|
||
(`WAI_SINK_EMULATOR_VULKAN=on` keeps it, and reproduces the crash); the decoder then finds no
|
||
adapter, states the GPU `absent`, and decoded every case on the CPU to the CPU's bytes and the
|
||
committed outputs. The start-up check and the decodes on a phone's or a television's Vulkan GPU
|
||
wait for a device. Until a run on one, nothing is claimed for that path on this platform; and a
|
||
device whose Vulkan driver fails the same way would end the application's process when the decoder
|
||
opens with the GPU offered, which `WaiIntDecoder.open(WaiIntDecoder.Gpu.OFF)` avoids.
|
||
|
||
## 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.
|