# Trillboards Partner Integration Guide — Native IMA SDK (Android)

**Status:** Production-verified. First successful integration: Take 10 Media, April 2026.
**Fill rate achieved:** 61% (Adipolo). Completion rate: 79% of fills. Effective: 48% of all ad requests.

This guide documents the exact integration that works for native Android IMA SDK on Rockchip-class devices. Follow it literally — every point here was discovered the hard way through a week of debugging.

---

## 0. Integration requirements — read this before you write any code

Every line below is a hard requirement, not a suggestion. Each one exists because a partner shipped without it and lost revenue or went black on-screen. If you only read one section of this guide, read this one.

**R1 — The waterfall is a LOOP, not a single tag.** `/ads` returns `header_bidding_settings.vast_waterfall.sources[]` — an **array**. You must iterate **every** source, in `priority` **ascending** order, and skip any source with `enabled: false`. Reading `sources[0].vast_url` and stopping is the single most common integration bug we see. Response shape: [§4](#4-the-ad-request--get-v1partnerdevicedeviceidads). Loop contract + pseudocode: [§4.1](#41-the-waterfall-loop-contract).

**R2 — Advance on EVERY failure, not just "no ad".** Move to the next source on no-fill, empty/zero-`<Ad>` VAST, HTTP error, per-source timeout, IMA **load** error, *and* IMA **playback** error. Named VAST codes you must treat as "try the next source", never as "stop": **303** (no ads VAST response after wrapper), **300** (wrapper URI unparseable/timeout), **301** (wrapper timeout / unresolvable), **1009** (empty VAST response). See [§4.1](#41-the-waterfall-loop-contract).

**R3 — Budget both clocks.** Each source has its own `timeout_ms` — apply it per source. The whole loop is capped by `vast_waterfall.total_timeout_ms` — when that budget is spent, stop trying sources and return to content. Never let one slow source eat the whole break.

**R4 — ALWAYS handle player error. Never leave the surface black.** Register `Player.Listener.onPlayerError` (Media3) or `AdErrorEvent.AdErrorListener` + your own content-playback error path (raw IMA). On any error you must fall back to your own content or a house creative. A black screen is worse than no ad — the venue sees it, and so does the earner. The exact ExoPlayer footgun that produced a fleet of black screens is worked through in [§6.1](#61-pitfall-the-uriempty-content-resume-black-screen-media3--ima).

**R5 — Fire the `<Error>` beacon.** Every VAST we serve — the programmatic wrap path *and* the first-party `trillboards_direct` rail — carries an `<Error>` element with the `[ERRORCODE]` macro. On any ad failure, substitute the numeric IMA/VAST error code for `[ERRORCODE]` and fire that URL. On the first-party rail this beacon is the **only** ad-failure signal that exists: we ship no agent to your player, so if you do not fire it we are structurally blind to your failures and cannot tell "nothing broke" from "everything broke". See [§7.1](#71-the-error-beacon--mandatory).

**R6 — Report `programmatic-event`.** `ad_started` / `ad_ended` / `no_fill` / `error`, each with `request_id` + `variant_name` from the source you actually used. See [§7](#7-reporting-events-back--post-v1partnerdevicedeviceidprogrammatic-event).

### Reference implementation and self-verification

- **Reference implementation:** [`trillboard-screen/public/partner-sdk/trillboards-lite.src.js`](https://github.com/trillboards/packages/blob/production/trillboard-screen/public/partner-sdk/trillboards-lite.src.js) — the Trillboards Lite SDK. Its `onAdError` handler is the canonical shape of R2 + R5: it classifies 303/1009 as no-fill, fires the `<Error>` pixel with `[ERRORCODE]` substituted, records the VAST request outcome, reports the `programmatic-event`, and **only** resolves the caller once the pending waterfall sources are exhausted. When in doubt about ordering or classification, copy what it does.
- **Self-verification:** append **`?test=1`** to your `/ads` call. That forces a guaranteed-fill test source (public alias `src_test`, a direct MP4 with no wrapper chain) into that one response — no allowlist enrollment, no revenue impact, real devices polling without the flag are unaffected. If `src_test` plays end-to-end but real sources don't, your VAST handling is fine and the problem is in the partner-response path. If `src_test` *doesn't* play, the bug is in your player.

---

## 1. What this guide is for

You are integrating the Trillboards `/ads` endpoint into a native Android player that uses Google IMA SDK (not WebView). Your devices are digital signage — kiosks, TVs, menu boards, ATM toppers — running a custom Android app with Media3 + ImaAdsLoader or raw IMA SDK.

This path is the highest-performing integration for partner devices. It gives you server-side wrapper chain resolution, device-specific demand targeting, and full impression attribution.

---

## 2. Required endpoints (five of them)

All base URLs: `https://api.trillboards.com`. Partner API key auth on registration only.

| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | POST | `/v1/partner/device/{deviceId}/heartbeat` | Every 60s. Reports device identity. |
| 2 | GET | `/v1/partner/device/{deviceId}/ads` | Request waterfall of VAST sources. |
| 3 | GET | `/openrtb/v1/vast-wrap/{provider}/{screenId}?url=...&req=...&var=...&pt=partner_api` | Fetch resolved VAST XML for a waterfall source. |
| 4 | POST | `/v1/partner/device/{deviceId}/programmatic-event` | Report ad_started / ad_ended / no_fill / error. |
| 5 | (automatic) | Tracking pixels embedded in the VAST | IMA SDK fires these when the ad plays. |

---

## 3. The heartbeat — player identity

**Endpoint:** `POST /v1/partner/device/{deviceId}/heartbeat` (every 60 seconds)

For a user-agent update, the supported body is `{"device":{"ua":"<actual WebView user agent>"}}`. The empty body remains a liveness heartbeat but provides no player UA. The following example includes optional hardware facts:
```json
{
  "device": {
    "make": "Rockchip",
    "model": "rk3568_r",
    "os": "Android",
    "osv": "11",
    "android_api_level": 30,
    "ua": "Mozilla/5.0 (Linux; Android 11; rk3568_r Build/RD2A.211001.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/105.0.5195.136 Safari/537.36"
  },
  "application": {
    "agentVersion": "2.0.18"
  }
}
```

### Field-by-field rules

**`device.make`** — The actual manufacturer reported by the device; use a known hardware value, not a guessed vendor.

**`device.model`** — The actual hardware model. Keep internal serial numbers and asset IDs in their identifier fields.

**`device.os`** — `Android`; report the release version separately in `device.osv`.

**`device.osv`** — The Android release version, for example `"11"` or `"13"`, from [`Build.VERSION.RELEASE`](https://developer.android.com/reference/android/os/Build.VERSION#RELEASE). Send `Build.VERSION.SDK_INT` separately as the integer `device.android_api_level`, for example `30` or `33`. This matches [OpenRTB's OS-version field](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3218---object-device).

**`device.ua`** — **The single most important field.** Read it directly from the device at app startup:
```kotlin
val webView = WebView(context)
val realUA = webView.settings.userAgentString
webView.destroy()
```
Or if WebView can't be instantiated (non-certified devices may restrict it):
```kotlin
val realUA = WebSettings.getDefaultUserAgent(context)
```

Send the exact UA returned by the installed WebView. Do not append serial numbers or manufacture unique strings, Build fingerprints, or `wv` tokens. Devices with the same software and hardware can legitimately share a UA. [`WebSettings.getDefaultUserAgent`](https://developer.android.com/reference/android/webkit/WebSettings#getDefaultUserAgent(android.content.Context)) returns the platform default.

**`application.agentVersion`** — Your SDK/app version. Helps us correlate issues to specific rollouts.

### Hardware model and asset identifiers

Keep serial numbers and asset-tracking IDs in the partner device identifier. If a device's model property has been repurposed as a serial number, use a verified hardware model or omit the optional model field. Preserve the UA returned by WebView.

---

## 4. The ad request — GET /v1/partner/device/{deviceId}/ads

**Endpoint:** `GET /v1/partner/device/{deviceId}/ads`

**Optional query params** (player truth — send whatever you know):
- `slot_w`, `slot_h` — Actual video container dimensions in pixels
- `orientation` — `portrait` or `landscape`
- `muted` — `1` or `0`
- `autoplay` — `1` or `0`
- `ua` — The real WebView UA (same as heartbeat), URL-encoded once by your URL builder
- `lang` — `en-US` etc.
- `screen_w`, `screen_h` — Physical screen dimensions in pixels
- `sdk_type` — `google-ima`
- `sdk_version` — IMA SDK version you're linking against
- Hardware and dimension fields are optional. Send actual pixel dimensions; omit unknown values. Start the UA update on a few identified devices and verify their outgoing requests before a fleet rollout.
- `ipd` — Inventory Partner Domain root domain, for example `trillboards.com`. Only send this when Trillboards tells you to; it must match an `inventorypartnerdomain=<domain>` declaration in the relevant ads.txt/app-ads.txt inventory-sharing setup, and it must not include a scheme, path, query, or port.

**Response shape:**
```json
{
  "success": true,
  "data": {
    "header_bidding_settings": {
      "enabled": true,
      "vast_waterfall": {
        "enabled": true,
        "total_timeout_ms": 15000,
        "sources": [
          {
            "name": "adipolo",
            "vast_url": "https://api.trillboards.com/openrtb/v1/vast-wrap/adipolo/{screenId}?url=...&req=req_...&var=adipolo&pt=partner_api",
            "timeout_ms": 10000,
            "priority": 1,
            "enabled": true,
            "cpm": 5.0,
            "degraded": false,
            "request_id": "req_1775716769503_eb810d042466"
          },
          {
            "name": "vidverto",
            "vast_url": "https://api.trillboards.com/openrtb/v1/vast-wrap/vidverto/{screenId}?url=...&req=req_...&var=vidverto&pt=partner_api",
            "timeout_ms": 10000,
            "priority": 2,
            "enabled": true,
            "cpm": 0.8,
            "degraded": false,
            "request_id": "req_1775716769503_8a85f754fd8a"
          },
          {
            "name": "trillboards_direct",
            "vast_url": "https://api.trillboards.com/openrtb/v1/vast?screenId=...&adId=...",
            "timeout_ms": 8000,
            "priority": 5,
            "enabled": true,
            "duration_seconds": 15,
            "request_id": "req_1775716769503_c31f0a9be7d2"
          }
        ]
      }
    },
    "screen_id": "69cce...",
    "dooh_signals": { ... }
  }
}
```

The `vast_url` values already point to our vast-wrap endpoint with `pt=partner_api`. **Do not rewrite them.** Use them exactly as given.

Each source carries its own `request_id` and `name` — save these. You'll need them for the `programmatic-event` report later (`variant_name` on the event is the source's `name`).

#### `duration_seconds` — for continuous loop playback

The first-party `trillboards_direct` source carries **`duration_seconds`**: the whole-second length of the creative that source will serve. It is there so you can schedule a continuous loop **without** fetching the VAST and probing the MP4 first.

- It is the **same number** as the `<Duration>` in the VAST that `vast_url` returns — the two are computed by one function server-side and cannot disagree. Use whichever is convenient.
- It is **scheduling metadata, not a playback control.** IMA takes its real timing from the media itself, so keep honouring the actual quartile and `complete` events rather than assuming the ad ended after `duration_seconds` elapsed. If the two ever disagree on your device, the *events* are right and that is a bug worth telling us about.
- Programmatic sources do **not** carry it — a DSP's creative length is unknown until its own VAST is fetched. Read it only off `trillboards_direct`, and treat it as absent everywhere else.
- A creative longer than your ad slot will still be cut off by the next `/ads` poll. We warn the advertiser at upload time when their creative exceeds the slot, so this should be rare — but do not assume `duration_seconds` fits your slot without checking.

Programmatic `/ads` responses are intentionally not HTTP-cacheable. Treat each call as a fresh ad opportunity because the VAST URLs include per-request IDs, correlators, and yield-routing metadata. Static direct-ad responses may still use short private ETag caching.

### 4.1 The waterfall loop contract

**`sources` is an ordered array and you must walk all of it.** This is not optional and it is not an optimization — it is how a Trillboards ad request is defined.

We return the array already in the order we want it tried, and `priority` ascends with array position. Sort by `priority` ascending anyway: it costs you three lines and it makes your player correct if the transport, a cache, or a JSON library ever reorders the array.

**Treat `priority` as an opaque ascending sort key — not an index.** Do not assume it starts at 1, and do not assume the values are contiguous:

- **`priority: 0` is real and valid.** A source we want to win outright is stamped `priority: 0` — that includes the `?test=1` source, an injected first-party bid, and an auction winner. A player that treats `0` as "unset" and skips it, or that starts its loop at 1, will drop exactly the source we most wanted served.
- **Gaps are normal.** Some paths renumber the surviving sources `1..N`; others leave the original catalog values, so `11` and `13` with nothing in between is a perfectly ordinary response.

Only the relative order carries meaning. `sortBy(priority)` handles all of this; arithmetic on the values does not.

```
sources ← response.data.header_bidding_settings.vast_waterfall.sources
sources ← sources.filter(s -> s.enabled != false).sortBy(s -> s.priority)   // ascending
deadline ← now() + vast_waterfall.total_timeout_ms

for (source in sources):
    if now() >= deadline: break                       // R3 — whole-loop budget spent
    vast ← GET source.vast_url  with timeout = source.timeout_ms
    if (transport failed) or (timed out) or (vast is empty) or (vast has zero <Ad>):
        lastTried ← source; continue                   // R2 — advance
    outcome ← handToIma(vast)
    if outcome is AD_LOAD_ERROR or AD_PLAY_ERROR:
        fire <Error> beacon with [ERRORCODE]           // R5 — always
        lastTried ← source; continue                   // R2 — advance
    report ad_started / ad_ended for this source       // §7
    return PLAYED                                      // stop at the FIRST source that plays

// every source exhausted, or budget spent
report no_fill for lastTried                           // §7
resumeOwnContent()                                     // R4 — never leave the surface black
```

**Rules the pseudocode encodes:**

- **Iterate all sources by `priority` ascending.** Stop only on the first source that *actually plays*.
- **Skip `enabled: false`.** A source can be present-but-disabled in the array; treat it as if it weren't there.
- **Advance on every failure class**, not just "no ad": no-fill, empty VAST, zero-`<Ad>` VAST, HTTP/transport error, per-source timeout, IMA **load** error, and IMA **playback** error. A creative that loads and then fails mid-roll still means "this source did not deliver" — go to the next one.
- **VAST error codes that mean "advance", never "stop":**

  | Code | Meaning | What you do |
  |---|---|---|
  | **303** | No ads VAST response after one or more wrappers | Next source |
  | **300** | Wrapper URI unparseable, unsupported, or wouldn't load | Next source |
  | **301** | Timeout / unresolvable URI in the wrapper chain | Next source |
  | **1009** | Empty VAST response | Next source |

- **Two clocks.** `source.timeout_ms` bounds one source's fetch+load. `vast_waterfall.total_timeout_ms` bounds the whole loop. Honour both; when the total budget is gone, stop and return to content.
- **`degraded: true`** means we've measured a high recent error rate for that source. We already push degraded sources to the tail of the array for you — you don't need to act on the flag, and you must **not** skip a source just because it's degraded.
- **Report against the source you actually used.** `request_id` and `variant_name` come from *that* source's entry, not from `sources[0]`. The `<Error>` beacon (R5) fires per failed source, always. The `programmatic-event` minimum is unchanged — the source that played, plus one `no_fill` for the last source tried if none did ([§7](#7-reporting-events-back--post-v1partnerdevicedeviceidprogrammatic-event)) — but a per-source `error` report is accepted and makes your waterfall legible to us end to end.

> **This is the exact bug that cost a partner a full production forensics session.** Their player read `sources[0].vast_url` only. Indexes 0–3 were programmatic DSPs that returned no-fill for their geo; the `trillboards_direct` house creative sat at index 4. Their player got VAST **303** on source 0 and stopped — one index before the ad it was looking for. Every request looked like "Trillboards has no demand". The demand was there the whole time, one loop iteration away.

---

## 5. Fetching VAST — the critical part

For **each** source in the waterfall — all of them, in `priority` ascending order, per the loop contract in [§4.1](#41-the-waterfall-loop-contract):

```
GET {source.vast_url}        # with a per-request timeout of source.timeout_ms
```

**Response:** Full VAST XML (Content-Type: application/xml), 15-25 KB, containing wrapper chains resolved server-side. Our server:
1. Fetches the upstream partner VAST (Adipolo, VidVerto, etc.) using your device's IP via `X-Forwarded-For` for geo-targeting
2. Follows `<VASTAdTagURI>` wrapper chains up to 5 hops to resolve to inline creative
3. Injects Trillboards tracking pixels at the wrapper/inline level
4. Returns the final VAST XML ready for IMA SDK to play

**Do NOT:**
- Parse the XML and extract only `<MediaFile>`
- Strip `<Wrapper>` elements
- Check for VPAID and skip (let IMA SDK handle it)
- Modify the XML in any way before passing to IMA

**Why:** Our tracking pixels (`<Impression>` and `<TrackingEvents>`) are embedded at the wrapper level. If you strip them, IMA SDK never fires them, and we have no record of the impression → no revenue attribution → no earnings.

---

## 5b. If you're NOT using Google IMA SDK (raw VAST player)

IMA SDK automatically fires every embedded `<Impression>` and `<TrackingEvents event="...">` URL as the ad plays. Custom VAST players do **not**. If you are rolling your own — raw HTML5 `<video>` wrappers, `svp`, `video.js` without the VAST plugin, Shaka without the ads plugin, or any other non-IMA stack — you are responsible for firing those pixels yourself.

**If you skip this, our server has no record of the impression. Zero revenue attribution. Zero earnings.**

Extract tracking URLs directly from the VAST XML with browser-native `DOMParser` — no dependencies:

```js
function parseVastTrackingUrls(vastXml) {
  const doc = new DOMParser().parseFromString(vastXml, 'application/xml');
  const getUrls = (selector) => Array.from(doc.querySelectorAll(selector))
    .map((n) => (n.textContent || '').trim())
    .filter(Boolean);
  return {
    impression:    getUrls('Impression'),
    error:         getUrls('Error'),   // fire on failure with [ERRORCODE] substituted — see §7.1
    start:         getUrls('Tracking[event="start"]'),
    firstQuartile: getUrls('Tracking[event="firstQuartile"]'),
    midpoint:      getUrls('Tracking[event="midpoint"]'),
    thirdQuartile: getUrls('Tracking[event="thirdQuartile"]'),
    complete:      getUrls('Tracking[event="complete"]'),
  };
}

function firePixels(urls) {
  urls.forEach((u) => { const img = new Image(1, 1); img.src = u; });
}
```

**Fire order:**
1. `Impression` URLs on the first video frame render.
2. `start` URLs immediately after the impression fire.
3. Quartile URLs (`firstQuartile`, `midpoint`, `thirdQuartile`, `complete`) from the corresponding player lifecycle callbacks (`timeupdate` / `ended` in HTML5, equivalent events in other stacks).
4. `error` URLs on any load or playback failure, with `[ERRORCODE]` replaced by the numeric VAST/player error code — see [§7.1](#71-the-error-beacon--mandatory).

**Rules:**
- Fire each URL exactly once per ad playback. Double-fire inflates our reconciliation counts and gets your supply flagged.
- Parse the VAST as XML, not regex. Tracking URLs live inside both `<InLine>` and `<Wrapper>` blocks — follow the chain like IMA does, or let our server resolve wrappers for you (we already do — see §5).
- The page hosting the player must allow our tracking host in its Content Security Policy: `img-src https://api.trillboards.com` (or wider). If CSP blocks the pixel request, the impression is lost silently.
- Use the `Image()` pattern (GET with no response handling) — not `fetch()` — so you don't inherit CORS constraints. Pixel endpoints are intentionally unauthenticated and idempotent.

---

## 6. Handing VAST XML to IMA SDK

### Raw IMA SDK (google-ima3)

```java
AdsRequest request = sdkFactory.createAdsRequest();
request.setAdsResponse(vastXmlString);  // the full XML, untouched
request.setContentProgressProvider(contentProgressProvider);
adsLoader.requestAds(request);
```

### Media3 + ImaAdsLoader (recommended for Android)

Media3 doesn't expose `setAdsResponse` directly — it uses a `data:` URI scheme that internally converts to `setAdsResponse`:

```kotlin
// 1. Get resolved VAST XML from vast-wrap
val vastXml = fetchVastWrap(source.vastUrl)

// 2. Build data: URI — Media3 internally calls setAdsResponse() for data: scheme
val adsUri = Util.getDataUriForString("text/xml", vastXml)

// 3. Pass to Media3 AdsConfiguration
val mediaItem = MediaItem.Builder()
    .setUri(contentUri)
    .setAdsConfiguration(
        MediaItem.AdsConfiguration.Builder(adsUri)
            .setAdsId(source.requestId)  // use source.request_id for tracking
            .build()
    )
    .build()

player.setMediaItem(mediaItem)
player.prepare()
```

### Critical: do NOT set MIME type filter

```kotlin
// WRONG — this blocks VPAID and non-mp4 creatives from VidVerto, AdX, etc.
ImaAdsLoader.Builder(context)
    .setAdMediaMimeTypes(listOf("video/mp4"))  // ← REMOVE THIS
    .build()

// RIGHT — let IMA SDK negotiate supported types per creative
ImaAdsLoader.Builder(context)
    .build()
```

The mp4 MIME filter was the single biggest completion-rate killer in Take 10's integration. Leaving it set cost 100% of VidVerto fills and some percentage of Adipolo fills. Remove it unconditionally.

### 6.1 Pitfall: the `Uri.EMPTY` content-resume black screen (Media3 + IMA)

This one is not partner-specific — it is a general IMA + ExoPlayer footgun, and it produced a fleet of black screens that took a full production forensics session to diagnose. Read it even if your integration "already works", because it only bites *after* an ad plays successfully.

**The setup.** Digital signage often has no "content" — the screen shows your own app UI or playlist, and you want the player to do nothing but play ads. So the natural thing to write is:

```kotlin
// WRONG — this is a black screen waiting to happen
val mediaItem = MediaItem.Builder()
    .setUri(Uri.EMPTY)                                // or "" , or omitted entirely
    .setAdsConfiguration(
        MediaItem.AdsConfiguration.Builder(adsUri).build()
    )
    .build()
```

**What actually happens.** The ad plays fine. Then IMA fires `CONTENT_RESUME_REQUESTED` at ad end — it always does, because IMA's model is *ad interrupts content, ad finishes, content resumes*. ExoPlayer dutifully opens the content URI. Now the failure chain runs:

1. The content URI is empty, so it has **no scheme**.
2. In media3, `Util.isLocalFileUri()` returns **true** for any scheme-less URI — an empty URI looks exactly like a relative local path.
3. So the load is routed to `FileDataSource`, which does `new RandomAccessFile("", "r")`.
4. That throws → `PlaybackException` with `ERROR_CODE_IO_FILE_NOT_FOUND`.
5. With no `onPlayerError` handler registered, nothing catches it. The player stops, the surface is never repainted, and the screen stays **black** until someone power-cycles it.

Note the shape of this bug: **there is no error on the ad path at all.** Fills are fine, completions are fine, your `programmatic-event` reports are fine. The failure is entirely in content-resume, which is why it survives every ad-side check you might run.

**The fix — two halves, and you need both.**

*(a) Give the player something real to resume into.* Point content at your own loop/filler asset, or a 1-frame looping local file, so `CONTENT_RESUME_REQUESTED` has a valid destination:

```kotlin
// RIGHT — content resume has a real, playable destination
val contentUri = Uri.parse("asset:///filler_loop.mp4")   // any real, local, scheme-ful URI
val mediaItem = MediaItem.Builder()
    .setUri(contentUri)
    .setAdsConfiguration(
        MediaItem.AdsConfiguration.Builder(adsUri)
            .setAdsId(source.requestId)
            .build()
    )
    .build()
```

If you genuinely have no content, use `ImaAdsLoader`'s ad-only path (`setPlayer` + a content source you control) rather than an empty URI. The invariant is simply: **never hand ExoPlayer a scheme-less or empty content URI.**

*(b) Always register an error handler (R4).* This is the part that turns any future variant of this bug into a recoverable blip instead of a black screen:

```kotlin
player.addListener(object : Player.Listener {
    override fun onPlayerError(error: PlaybackException) {
        Log.e("TrillboardsAds", "player error ${error.errorCodeName}", error)
        // Do not stop here. Recover the SURFACE:
        showOwnContent()     // your playlist / house creative / static frame
        // and tell us, so the failure is visible on our side too:
        reportProgrammaticEvent(
            event = "error",
            requestId = currentSource.requestId,
            variantName = currentSource.name,
            errorCode = error.errorCodeName,
            errorMessage = error.message ?: "player error",
        )
        fireVastErrorBeacon(currentVastErrorUrl, error.errorCodeName)   // §7.1
    }
})
```

`onPlayerError` is **not** the same listener as IMA's `AdErrorEvent.AdErrorListener`. `AdErrorListener` covers ad load/play failures; `onPlayerError` covers everything ExoPlayer itself fails at, including content resume. Register **both**. The Lite SDK reference implementation does exactly this — ad errors go through `onAdError`, and the host gets a callback so it can repaint its own surface.

---

## 7. Reporting events back — POST /v1/partner/device/{deviceId}/programmatic-event

Fire these from your player lifecycle callbacks. You only need to report for the **one source that actually played** (the first accepted source in the waterfall). You do not need to report for sources that were skipped.

### When IMA SDK fires `STARTED`
```http
POST /v1/partner/device/{deviceId}/programmatic-event
Content-Type: application/json

{
  "event": "ad_started",
  "request_id": "req_1775716769503_eb810d042466",
  "variant_name": "adipolo",
  "creative_id": "<from IMA AdEvent.getAd().getCreativeId()>",
  "impression_id": "<from IMA AdEvent.getAd().getAdId() or generate UUID>"
}
```

### When IMA SDK fires `COMPLETE`
```http
POST /v1/partner/device/{deviceId}/programmatic-event

{
  "event": "ad_ended",
  "request_id": "req_1775716769503_eb810d042466",
  "variant_name": "adipolo",
  "creative_id": "<same as ad_started>",
  "impression_id": "<same as ad_started>"
}
```

### When the waterfall returns no ads
```http
POST /v1/partner/device/{deviceId}/programmatic-event

{
  "event": "no_fill",
  "request_id": "<request_id from the last source tried>",
  "variant_name": "<last variant name>"
}
```

### When IMA SDK fires `ERROR`
```http
POST /v1/partner/device/{deviceId}/programmatic-event

{
  "event": "error",
  "request_id": "...",
  "variant_name": "...",
  "error_code": "<IMA error code>",
  "error_message": "<IMA error message>"
}
```

### Required fields

You **must** include `request_id` and `variant_name` on every event. Events without these are silently dropped on our side.

### 7.1 The `<Error>` beacon — mandatory

`programmatic-event` is our HTTP report channel. The VAST `<Error>` element is the *IAB-standard* one, and it is **not** optional. Fire both.

Every VAST we serve carries an `<Error>` URL with the IAB `[ERRORCODE]` macro in it — on the programmatic wrap path, and, as of the signed-beacon change that shipped on the first-party direct rail, on `trillboards_direct` too. The URL is HMAC-**signed** by us (`sig=`), so it is only fireable as the beacon we minted:

```xml
<Error><![CDATA[https://api.trillboards.com/openrtb/v1/event?...&event=error&sig=...&errorCode=[ERRORCODE]]]></Error>
```

**On any ad failure, substitute the numeric IMA/VAST error code for the literal `[ERRORCODE]` and issue a GET to that URL.** IMA fires wrapper-level `<Error>` URLs for you when it can, but it cannot fire them for failures it never sees (your own fetch timeouts, your transport errors, a playback error you caught yourself) — so a custom or partly-custom player must fire them itself. Fire it once per failed source.

```kotlin
fun fireVastErrorBeacon(errorUrlFromVast: String?, errorCode: Any?) {
    val url = errorUrlFromVast ?: return
    val substituted = url.replace("[ERRORCODE]", Uri.encode(errorCode?.toString() ?: ""))
    // fire-and-forget GET; unauthenticated + idempotent, no response handling needed
    Thread { runCatching { URL(substituted).openStream().close() } }.start()
}
```

**Why this matters more than it looks.** On the first-party `trillboards_direct` rail the player is yours and we ship no agent onto it, so the `<Error>` beacon is the **only** ad-failure signal that exists for that rail. Without it we are structurally error-blind: our lifecycle rollups read `error_count = 0` for `self_promo` / `paid_direct` not because nothing failed, but because nothing *could report*. That blindness is precisely what turned one partner's black screen into a full production forensics session — there was no signal to look at.

Notes on the URL:

- `errorCode` is deliberately **outside** our signature, so substituting `[ERRORCODE]` can never invalidate the beacon. Do not strip, re-encode, or reorder the other query parameters — pass the URL through with only the macro replaced.
- `event` **is** signed, so an `error` beacon can never be replayed as a `complete`. The beacon is pure telemetry and can never move money — there is no reason to withhold it.
- Same dedup semantics as our other pixels; a duplicate fire collapses server-side.

The Lite SDK does all of this in one place — see `onAdError` in `trillboards-lite.src.js`: `[ERRORCODE]` substitution, `keepalive` GET (not `sendBeacon` — the endpoint is GET-only), then `trackVastRequest` and `reportProgrammaticEvent`. If you copy one function out of the reference implementation, copy that one.

**Custom / non-IMA players:** the `<Error>` URL is extracted the same way as the tracking URLs in [§5b](#5b-if-youre-not-using-google-ima-sdk-raw-vast-player) — add `error: getUrls('Error')` to `parseVastTrackingUrls`, and preserve `<Error>` at every wrapper level of the chain, exactly as you do for `<Impression>`.

### Automatic tracking (no code required)

On top of `programmatic-event`, our server also receives impression events automatically via tracking pixels embedded in the VAST XML. When IMA SDK processes the VAST and fires the `<Impression>` tag, it hits `https://api.trillboards.com/openrtb/v1/pixel?...&provider=adipolo` automatically. You don't need to do anything for this — it happens because you passed the full unmodified VAST to IMA.

Both paths (programmatic-event and tracking pixels) are deduplicated on our side by `request_id + impression_id`.

---

## 8. Verification — how to tell it's working

Ask your Trillboards contact to run these:

```bash
# Live fills from your devices
aws logs tail /ecs/trillboards-api --since 5m | grep 'vast-wrap.*adipolo/<screenId>.*fill=true'
```

Expected output format:
```
[vast-wrap] adipolo/{screenId} client={device IP} fill=true type=wrapper vpaid=false pt=partner_api size=21378 204ms
```

`fill=true` means Adipolo returned a real ad creative. `pt=partner_api` confirms your integration path. `size=21378` is a normal Adipolo wrapper response size — if it's much smaller the wrapper chain didn't resolve.

Completions arrive ~3-15 seconds after the fill. You'll know it's end-to-end working when you see `completed_impressions` rows with:
- `source_provider = 'adipolo'`
- `ingestion_path = 'vast_event'` (from the tracking pixels embedded in the VAST — what IMA fires automatically, and what a custom player must fire itself per [§5b](#5b-if-youre-not-using-google-ima-sdk-raw-vast-player)) OR `ingestion_path = 'proof_of_play'` OR `ingestion_path = 'partner_sdk'` (from your `programmatic-event` report)
- `request_id` matches the one from the waterfall source

> **Do not use `ingestion_path = 'impression_callback'` as your success signal.** That
> path is guarded and deliberately excluded from the revenue rollup — rows carrying it
> are not counted, so "I can see `impression_callback` rows" is not evidence that
> anything settled. `vast_event` and `proof_of_play` are the billing-grade paths.

A booked first-party direct ad delivered on the `data.ads` rail settles under
`ingestion_path = 'owned_direct'` instead, from the signed
`tracking.owned_direct_complete_url` beacon on the ad object. Fire that URL verbatim —
signature and all — and never rebuild it.

---

## 9. Common failure modes we've seen

### "You never have any demand — every request is a no-fill"
You are almost certainly reading `sources[0]` and stopping. Programmatic DSPs no-fill constantly and legitimately (wrong geo, no bid, budget exhausted) — the waterfall exists precisely so the *next* source gets a turn, and our own guaranteed `trillboards_direct` creative often sits several indexes down. Walk every source by `priority` ascending, skip `enabled: false`, and advance on 303/300/301/1009. See [§4.1](#41-the-waterfall-loop-contract) — this exact bug cost a partner a full production forensics session.

### "The screen goes black after every ad"
Content resume, not the ad. Your content `MediaItem` almost certainly has an empty or scheme-less URI, so media3 routes it to `FileDataSource` and dies with `ERROR_CODE_IO_FILE_NOT_FOUND` the moment IMA fires `CONTENT_RESUME_REQUESTED`. Full chain and both halves of the fix: [§6.1](#61-pitfall-the-uriempty-content-resume-black-screen-media3--ima). The tell is that ad-side metrics look perfect — fills and completions are fine, because nothing on the ad path failed.

### "Something is failing but you can't see anything on your side"
You are not firing the VAST `<Error>` beacon. On the first-party `trillboards_direct` rail it is the only ad-failure signal that exists — no beacon means our error counts read zero no matter how badly your player is failing. See [§7.1](#71-the-error-beacon--mandatory).

### "I'm getting fills but no completions"
Either:
1. Your IMA SDK has `setAdMediaMimeTypes("video/mp4")` set — remove it.
2. You're stripping the VAST wrapper before passing it to IMA — pass the full XML unmodified.
3. Your device can't reach `api.trillboards.com` to fire the tracking pixels (firewall / DNS).

### "My heartbeat sends device fields but you don't see them"
The heartbeat field must be `device.ua` (not `device.uaSample` or `telemetry.ua`). Our normalizer reads `body.device.ua`.

### "Fill rate is 1% when our own Rockchip screens fill at 60%"
Your heartbeat identity is bad. Check:
- `make` = the actual device manufacturer; do not infer it from the chipset name
- `model` = the actual hardware model; keep device serials and asset IDs in their identifier fields
- `osv` = Android release version (`"11"` or `"13"`); send the API level separately as `device.android_api_level` (`30` or `33`)
- `ua` = the actual WebView UA; identical models may legitimately report the same value, so do not invent unique identifiers or Build tokens

### "VidVerto specifically isn't completing"
VidVerto serves VPAID creatives. Native IMA SDK needs `setAdMediaMimeTypes` to be unset (or include `application/javascript`). This is the default — only override it if you're certain your player can only play mp4.

### "I want to test the wire format without building a full SDK"
Append **`?test=1`** to your `/ads` call. That forces a guaranteed-fill test source into that one response — public alias **`src_test`**, a direct MP4 URL with no wrapper chain to resolve. No allowlist enrollment is needed, it's per-request only (real devices polling without the flag are unaffected), and the creative is non-revenue. If `src_test` completes but real partners don't, your VAST handling is fine and the problem is in the partner response path (probably MIME filter). If `src_test` *doesn't* complete, the bug is in your player — start with [§4.1](#41-the-waterfall-loop-contract) and [§6.1](#61-pitfall-the-uriempty-content-resume-black-screen-media3--ima).

---

## 10. What's happening server-side (optional context)

When you GET a vast-wrap URL with `pt=partner_api`, our server does this:

1. **Fetches** the upstream partner VAST (e.g., Adipolo's `pubads.adipolo.co/ctv/ad?...`) with your device's IP in `X-Forwarded-For` so DSPs geo-target correctly.
2. **Resolves wrapper chains** up to 5 hops. Adipolo returns a wrapper pointing to GAM; we follow it and get the inline creative. Your device never sees the chain.
3. **Injects tracking pixels** at the outermost wrapper level — `<Impression>` and `<TrackingEvents>` for start/quartile/complete. These include `&provider={source_name}` so attribution is baked in at injection time.
4. **Returns** the final VAST XML (inline or resolved wrapper) with our pixels embedded.

You get back a VAST that's ready to hand straight to IMA SDK with no processing on your end.

---

## 11. Reference implementation

### The canonical code: the Trillboards Lite SDK

[`trillboard-screen/public/partner-sdk/trillboards-lite.src.js`](https://github.com/trillboards/packages/blob/production/trillboard-screen/public/partner-sdk/trillboards-lite.src.js) is our own player, and it is the reference implementation for everything in [§0](#0-integration-requirements--read-this-before-you-write-any-code). It's JavaScript, but the *control flow* is what you're copying, and it ports directly to Kotlin.

Read `onAdError` first. In one handler it does all of this, in this order:

1. Pulls `errorCode` + `errorMessage` off the IMA `AdErrorEvent`.
2. Classifies the failure — `1009` / `303` / a message containing `no fill` or `no ads vast response` is a **no-fill**; anything else is an **error**. (R2)
3. Fires the VAST `<Error>` URL with `[ERRORCODE]` substituted for the real code, as a `keepalive` GET. (R5)
4. Records the VAST request outcome (`no_fill` vs `error`) with latency and source name.
5. Reports the `programmatic-event` with `request_id`, `variant_name`, `error_code`, `error_message`. (R6)
6. **Only resolves the caller when `_pendingWaterfallSources` is empty** — while sources remain, it advances to the next one instead of finishing. (R1 + R2)
7. Invokes a host callback so the embedding app can repaint its own surface. (R4)

Points 6 and 7 are the two that partners most often miss, and they're the two that produce the two worst failure modes: a waterfall that gives up at index 0, and a screen that stays black.

### The reference deployment: Take 10 Media

Take 10 Media (`com.take10media.adplayer`) is the reference *native Android* integration. Key facts about their working setup:

- Native Android IMA SDK via Media3 + ImaAdsLoader 1.10.0
- Rockchip devices (`rk3288`, `rk3568_r`) running Android 9-14
- **No** Google Play Services (the devices are not Google-certified — this is fine)
- Agent version 2.0.18 (removed `setAdMediaMimeTypes` in this version)
- Heartbeat every 60 seconds with full WebView UA
- VAST XML passed to IMA via `data:text/xml` URI (Media3 internally calls `setAdsResponse`)
- Events reported via `programmatic-event` for the first played source only

Fill rate: 61% Adipolo. Completion rate: 79% of fills. Effective: ~48% of all ad requests result in billable completions.

---

## 12. Trillboards Sensing SDK (Android) — optional, alongside the IMA integration

Two artifacts cover two distinct hardware classes. Pick by what your screens actually have, NOT by what features you might want — installing the wrong one wastes bundle size and (for AGP <8) won't even link.

### Which SDK matches your hardware?

- **Camera/mic-equipped retail screens** (digital signage, in-store displays with an audience-facing camera) → **`com.trillboards:agent-core:1.14.0`** — full Sensing SDK with face / audio / speech / pose / VLM. See §12.B.
- **Camera-less appliance screens** (vending machines, ATMs, EV charging stations, self-checkout, smart lockers, transit shelters) → **`com.trillboards:ctv-measurement:1.3.2`** — passive BLE / WiFi / mDNS / SSDP / HTTP-probe / native-sensor capture only. See §12.A.

Both ship sensing data over the same `/openrtb/v1/heartbeat` endpoint. After
registration, liveness and targeting-identity heartbeats require no Partner API
key; the stable registered device ID supplies the screen binding. The two SDKs
are wire-format-compatible, so partners with a mixed fleet (some
camera-equipped + some appliance) emit identically-shaped heartbeats from both.

### Build compatibility matrix

| Artifact | Hardware profile | Min AGP | Min Gradle | Min JDK | Min Android | Bundle | Jetifier OK |
|---|---|---|---|---|---|---|---|
| `ctv-measurement` | Camera-less appliance (vending / ATM / charging / kiosk) | **4.1** | 6.5 | 8 | 24 (Android 7.0) | ~3 MB | **Yes** |
| `agent-core` (full) | Camera + mic equipped retail screen | 8.0 | 8.0 | 17 | 26 (Android 8.0) | ~180 MB | No |

> **Why the AGP gap?** `agent-core` bundles CameraX 1.5.3, which embeds `minAndroidGradlePluginVersion=8.0.0` in its AAR metadata — AGP 4.x consumers will hard-fail at `checkDebugAarMetadata`. `ctv-measurement` ships Java 1.8 bytecode (class v52) with no camera/ML deps, so AGP 4.1 + Jetifier (ASM 8, max class v58) can consume it cleanly.

### What the backend does with the raw signals

Whichever SDK you install, you ship raw signals — Trillboards' audience resolver does all the math server-side:

- **BLE proximity** → presence-graph contribution + dwell estimation
- **WiFi network analysis** → venue intelligence (network density, RSSI variance as motion proxy, frequency-band ladder for venue-type classification)
- **mDNS / SSDP / HTTP-probe** → on-network device-class fingerprint (smart TVs, IoT hubs, point-of-sale terminals, printers) feeding co-viewing inference
- **Native sensors** (accelerometer, light, barometer, proximity) → ambient context for footfall / illumination cross-checks

All derived signals are attached to your bid requests automatically. You do not run any aggregation, ML, or schema-translation code client-side. Updating to a newer SDK version never requires backend coordination — the wire contract is stable.

---

## 12.A Camera-less appliances — `ctv-measurement`

For vending machines, ATMs, EV charging stations, self-checkout kiosks, smart lockers, and other Android appliance hardware **without cameras or microphones**.

**Maven coordinate:** `com.trillboards:ctv-measurement:1.3.2`

**Registry:** `maven.trillboards.com` (anonymous read, no auth tokens — the canonical Trillboards SDK Maven CDN).

**Source mirror:** https://github.com/trillboards/ctv-measurement-sdk (Apache 2.0).

### 12.A.1 Add the Maven registry

In your project's `settings.gradle.kts` (or root `build.gradle.kts`):

```kotlin
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://maven.trillboards.com") }
    }
}
```

### 12.A.2 Add the dependency

In your app module's `build.gradle.kts`:

```kotlin
dependencies {
    implementation("com.trillboards:ctv-measurement:1.3.2")
}
```

The AAR is self-contained: BLE / WiFi / mDNS / SSDP / HTTP-probe / native-sensor scanners, plus a Rust-backed mDNS discovery layer (UniFFI bindings + per-ABI `.so` for `arm64-v8a` and `armeabi-v7a`). No camera, no microphone, no ML — works as-is on AGP 4.1+ + Jetifier.

### 12.A.3 Manifest permissions

Declare in your app's `AndroidManifest.xml`:

```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />

<!-- BLE — API 31+ uses BLUETOOTH_SCAN + neverForLocation; older API falls back to ACCESS_FINE_LOCATION -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
    android:maxSdkVersion="30" />

<!-- WiFi — API 33+ uses NEARBY_WIFI_DEVICES; older API uses ACCESS_WIFI_STATE -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
    android:usesPermissionFlags="neverForLocation" />
```

**Both `BLUETOOTH_SCAN` and `NEARBY_WIFI_DEVICES` are runtime-dangerous — you
must request each one at runtime.** Declaring them (or letting the AAR's
manifest merge in) is not a grant. Requesting only `BLUETOOTH_SCAN` yields BLE
observations and a permanent zero on WiFi; requesting only `NEARBY_WIFI_DEVICES`
yields the reverse. Below API 31 BLE falls back to `ACCESS_FINE_LOCATION`.
**WiFi scanning needs `ACCESS_FINE_LOCATION` at every API level — including
33+, where it is required *in addition to* `NEARBY_WIFI_DEVICES`** — because
`WifiManager.startScan()`/`getScanResults()` are the documented carve-out that
`NEARBY_WIFI_DEVICES` does not cover. The AAR caps its own declaration at
`maxSdkVersion="30"`, so on API 31+ you must declare an uncapped
`ACCESS_FINE_LOCATION` yourself. Copy the per-API-level `requestPermissions`
snippet and the logcat verification from
[CTV Measurement SDK — Partner Integration Guide § Step 1b](https://api.trillboards.com/docs/integrations/ctv-measurement-partner-integration.md).

Radio observations remain bounded to the measurement contract. Targeting
identities are separate: the SDK sends the real resettable GAID/AAID when the
platform exposes one, and can send exact host-owned HEM/UID2 values. It never
uses a screen or registered-device UUID as a MAID.

### 12.A.4 Initialize the SDK

Call once from `Application.onCreate()`:

```kotlin
import com.trillboards.measurement.*

TrillboardsMeasurement.initialize(
    context = applicationContext,
    config = MeasurementConfig.Builder(
            deviceId = yourStableRegisteredDeviceId,
        )
        .scanIntervalMs(30_000L)
        .enabledSources(setOf(
            SignalSource.BLE, SignalSource.WIFI, SignalSource.MDNS,
            SignalSource.SSDP, SignalSource.HTTP_PROBE, SignalSource.NATIVE_SENSOR,
        ))
        .build(),
)
TrillboardsMeasurement.setConsentStatus(true)
TrillboardsMeasurement.startScheduledScans()
```

The SDK posts to `/openrtb/v1/heartbeat` on its own schedule without an API
key. Use the same stable ID registered as the device's `external_id`. Call
`TrillboardsMeasurement.shutdown()` if you need to wind sensing down (rare).

### 12.A.5 WebView bridge (optional)

If your ad creatives need to read cached snapshots from JavaScript:

```kotlin
TrillboardsMeasurement.attachToWebView(myWebView)
// Inside the ad creative:
//   window.TrillboardsMeasurement.getSnapshot()   → JSON string
//   window.TrillboardsMeasurement.getVersion()    → bridge version
```

Bridge must be attached **before** `WebView.loadUrl()`. The SDK holds the WebView via a `WeakReference` so there's no Activity/Fragment leak risk.

---

## 12.B Camera/mic-equipped retail screens — `agent-core`

For retail screens with audience-facing cameras and/or microphones (digital signage, in-store displays, kiosks with depth/RGB cameras). Face detection, attention estimation, audio classification, speech ASR, pose and VLM inference run on the screen. The agent sends Trillboards a record for each detected person, a face-identity template on the full sensing profile, speech transcripts, the Bluetooth, Wi-Fi and local-network identifiers of nearby devices, the screen's GPS position, and selected camera frames for cloud analysis.

**Maven coordinate:** `com.trillboards:agent-core:1.14.0`

**Registry:** `maven.trillboards.com` (anonymous read, no auth tokens — the canonical Trillboards SDK Maven CDN).

**Source mirror:** https://github.com/trillboards/agent-core (Apache 2.0).

### 12.B.1 Add the Maven registry

In your project's `settings.gradle.kts` (or root `build.gradle.kts`):

```kotlin
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://maven.trillboards.com") }
    }
}
```

No credentials needed — `maven.trillboards.com` serves every Trillboards Sensing SDK release anonymously from the canonical Maven CDN. Pin an exact version (`com.trillboards:agent-core:1.14.0`) or float the minor line (`com.trillboards:agent-core:1.14.+`). The public source mirror lives at `trillboards/agent-core`.

### 12.B.2 Add the dependency

In your app module's `build.gradle.kts`:

```kotlin
dependencies {
    implementation("com.trillboards:agent-core:1.14.0")
}
```

The AAR pulls MediaPipe Tasks, TensorFlow Lite, CameraX, and Media3 transitively — face detection, audio classification, BLE/WiFi co-viewing, and demographic inference work out of the box. (sherpa-onnx ASR is opt-in via the BYO flow in §12.5b.)

**Do not exclude transitive groups to disable a modality.** agent-core hard-codes references to `com.google.mediapipe.tasks.audio.*`, `com.google.mediapipe.tasks.vision.*`, and friends; excluding the corresponding artifact at the Gradle level produces a `NoClassDefFoundError` at runtime the first time the agent tries to instantiate that processor — long after your CI passes. Use the `SensingSdkConfig` runtime flags in §12.4 instead:

```kotlin
TrillboardsSensingSdk.start(
    context = applicationContext,
    config = SensingSdkConfig(
        deviceId = yourStableRegisteredDeviceId,
        audioClassificationEnabled = false,  // mic-less screens
        speechIntelligenceEnabled = false,   // no ASR
        // …other modality flags
    )
)
```

This keeps the dep graph valid while skipping the processors at runtime — same effect, no crash risk.

### 12.B.3 Manifest permissions

Declare in your app's `AndroidManifest.xml`:

```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />        <!-- API 31+ -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />     <!-- API 31+, required for BLE GATT 0x180A device-info enrichment -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />  <!-- API <31 fallback for BLE; required for WiFi scanning at EVERY API level, incl. 33+ -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />  <!-- API 33+ WiFi scan -->
<uses-permission android:name="android.permission.INTERNET" />
```

Camera, microphone, **`BLUETOOTH_SCAN` (API 31+), and `NEARBY_WIFI_DEVICES` (API 33+)** are all runtime permissions — request every one of them via the standard Android prompt before starting the agent. The radio permissions are the easy ones to miss because they merge in from the AAR and never prompt on their own: skip the `NEARBY_WIFI_DEVICES` request and WiFi scanning returns nothing on Android 13+ while BLE keeps reporting normally. Use the per-API-level snippet in [CTV Measurement SDK — Partner Integration Guide § Step 1b](https://api.trillboards.com/docs/integrations/ctv-measurement-partner-integration.md). The agent records no video and no audio; everything it sends is listed at the top of §12.B.

### 12.B.4 Initialize the SDK

Call `TrillboardsSensingSdk.start()` once — typically from
`MainActivity.onCreate()` or `Application.onCreate()`. Pass the same stable
device ID used during registration. Do not embed a Partner API key; the SDK
resolves the registered screen and manages any privileged sensing-channel
credential internally.

```kotlin
import com.trillboards.sdk.SensingSdkConfig
import com.trillboards.sdk.TrillboardsSensingSdk

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        TrillboardsSensingSdk.start(
            context = applicationContext,
            config = SensingSdkConfig(
                deviceId = yourStableRegisteredDeviceId
            )
        )
    }
}
```

Need to disable a modality (e.g., your screens have no microphone) or point at a non-prod environment? Pass a `SensingSdkConfig`:

```kotlin
TrillboardsSensingSdk.start(
    context = applicationContext,
    config = SensingSdkConfig(
        deviceId = yourStableRegisteredDeviceId,
        audioClassificationEnabled = false,
        speechIntelligenceEnabled = false,
        apiBaseUrl = "https://staging.api.trillboards.com",
        socketUrl = "https://staging.chat.trillboards.com"
    )
)
```

Call `TrillboardsSensingSdk.stop(applicationContext)` if you need to wind sensing down (rare — the SDK is designed to run continuously alongside your ad-serving loop).

That's the minimum integration. The agent handles camera initialization, model OTA download (~178 MB FaceXFormer + Moonshine, downloaded once on first run, then cached), heartbeat scheduling, and Socket.io reconnection internally.

### 12.B.5 What you get on the wire

The agent emits two channels:

1. **HTTP heartbeat** — `POST /openrtb/v1/heartbeat` every 30s. Carries device
   health, capabilities, BLE/WiFi co-viewing arrays, the actual platform MAID
   when available, and typed host-provided HEM/UID2. The POST is unauthenticated
   after device registration; the device/screen binding provides attribution.
   Read live state with authenticated
   `GET /v1/partner/device/{deviceId}/heartbeat` (see API reference).
2. **Real-time signals** — Socket.io to `chat.trillboards.com`, room joined automatically on `start()`. Events: `audienceSignals` (face/audio/speech detections), `audienceDemographics` (rolling demographic estimates), `deviceCapabilities` (model availability). These feed our backend for the audience-attention flywheel; you don't need to subscribe to them directly unless you want to mirror the data.

### 12.B.6 Speech recognition

**agent-core 1.22 and later: included, nothing to add.** On-device speech recognition (Moonshine ASR via [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)) comes with the SDK. agent-core depends on `com.trillboards.bundled:sherpa-onnx-static-ort:1.12.40`, served from the same repository as agent-core, and carries the Moonshine model in its assets. Remove any `sherpa-onnx-*.aar` you added to `libs/` for an earlier agent-core: two copies of `com.k2fsa.sherpa.onnx` on one classpath fail the build as duplicate classes.

- **Your own ONNX Runtime is safe.** The bundled sherpa-onnx links ONNX Runtime statically and exports none of its symbols, so an app that also depends on `com.microsoft.onnxruntime:onnxruntime-android` (any version) builds with no `pickFirst` and runs both. (If your app uses ONNX Runtime with R8, keep `ai.onnxruntime.**` in your own rules — onnxruntime-android does not ship that rule.)
- **R8 / minified builds need nothing from you** for agent-core: its consumer ProGuard rules keep the sherpa-onnx classes its JNI reads by name and the LiteRT-LM classes it loads by reflection.
- **APK size (arm64-v8a):** the sherpa-onnx JNI library is 22.2 MB (the shared-ORT AAR earlier versions asked you to add was 35.1 MB for the same ABI).

Spanish transcription and spoken-language ID use a Whisper model that is delivered over the air to screens it is rolled out to; it is not in the APK.

**Earlier agent-core versions: bring your own sherpa-onnx.** There speech recognition is opt-in, because agent-core declares sherpa-onnx `compileOnly`. Face detection, audio classification, BLE/WiFi co-viewing and demographics work without any extra setup; only speech recognition is gated. To turn it on:

1. Download `sherpa-onnx-1.12.26.aar` from the [sherpa-onnx releases page](https://github.com/k2-fsa/sherpa-onnx/releases) (or contact `engineering@trillboards.com` for the Trillboards-bundled copy with the matching native libs).
2. Drop it into your app's `libs/` folder.
3. Register a `flatDir` repository so Gradle can resolve it:

   ```kotlin
   // settings.gradle.kts
   dependencyResolutionManagement {
       repositories {
           flatDir { dirs(file("app/libs")) }
       }
   }
   ```

4. Add the implementation dep:

   ```kotlin
   // app/build.gradle.kts
   dependencies {
       implementation(":sherpa-onnx-1.12.26@aar")
   }
   ```

5. Bundle the Moonshine model assets under `app/src/main/assets/moonshine-tiny/`. The agent's runtime probe looks for `moonshine-tiny/tokens.txt`; if the file isn't present, speech recognition is silently skipped without affecting other modalities.

### 12.B.7 Reference implementation

`com.trillboards:agent-core:1.14.0` ships the canonical Kotlin codebase that powers Trillboards' first-party retail screens. Source mirror lives at https://github.com/trillboards/agent-core (Apache 2.0).

---

## 12.C Cross-references (both SDKs)

- **WebView ad creatives that need to read sensing data**: [`docs/integrations/partner-native-bridge.md`](https://github.com/trillboards/packages/blob/production/docs/integrations/partner-native-bridge.md) — the `__TRILL_BRIDGE_V1__` JS interface the agent injects into your WebView.
- **CTV measurement integration patterns** (frame-rate budgeting, signal mapping, partner OEM cookbook): [`trillboard-api/docs/integrations/ctv-measurement-partner-integration.md`](https://api.trillboards.com/docs/integrations/ctv-measurement-partner-integration.md).
- **Embed SDK for partner CMS surfaces** (web players, kiosk shells embedding Trillboards content): [`docs/integrations/partner-embed-sdk.md`](https://github.com/trillboards/packages/blob/production/docs/integrations/partner-embed-sdk.md).
- **Browser/CTV sensing without native Android**: `npm i @trillboards/ctv-measurement` for browser-side BLE bridge consumers + Census enrichment + QR attribution.
- **Linux/Windows DOOH partners** (no Android): `npm i @trillboards/edge-sdk` for V4L2 / DirectShow / BlueZ-based sensing.
- **Zero-friction NPM facade for vending / kiosk monetization** (no native code): `npm i @trillboards/connect`.

The Sensing SDK page on the developer hub at `https://trillboards.com/support/developers/sensing-sdk` covers configuration tuning, what the SDK sends and how long Trillboards keeps it, model lifecycle, and capability tier defaults.

---

## 13. Support

When something breaks, share the following with Trillboards support:

1. **A screen_id** that's actively requesting ads
2. **Your device's actual heartbeat body** (the HTTP POST body, not a logcat sample)
3. **The full `vast_waterfall` object** from one `/ads` response — the whole `sources[]` array, not just the entry you used — plus **which indexes your player actually tried**. This one line of evidence separates "no demand" from "your loop stopped at index 0" instantly.
4. **The VAST response you got back** from vast-wrap for one request (save the raw XML)
5. **IMA SDK error codes** if playback fails (from the `AdErrorEvent` callback) **and** any `PlaybackException` from `onPlayerError` — they are different listeners and different failure classes ([§6.1](#61-pitfall-the-uriempty-content-resume-black-screen-media3--ima))
6. **Your `programmatic-event` POST body** for both ad_started and ad_ended for one ad

With these six artifacts we can trace the complete request lifecycle and tell you exactly what's broken.
