# 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 = "") 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 127.0.0.1 ``` 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:`, the version of `wai/wai-rs`: attached to the release as `wai-sink-.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:") } ``` 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 ``` 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.