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. Loop contract + pseudocode: §4.1.
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.
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.
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.
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.
Reference implementation and self-verification
- Reference implementation:
trillboard-screen/public/partner-sdk/trillboards-lite.src.js— the Trillboards Lite SDK. ItsonAdErrorhandler 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 theprogrammatic-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=1to your/adscall. That forces a guaranteed-fill test source (public aliassrc_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. Ifsrc_testplays end-to-end but real sources don't, your VAST handling is fine and the problem is in the partner-response path. Ifsrc_testdoesn'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:
{
"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. 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.
device.ua — The single most important field. Read it directly from the device at app startup:
val webView = WebView(context)
val realUA = webView.settings.userAgentString
webView.destroy()
Or if WebView can't be instantiated (non-certified devices may restrict it):
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) 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 pixelsorientation—portraitorlandscapemuted—1or0autoplay—1or0ua— The real WebView UA (same as heartbeat), URL-encoded once by your URL builderlang—en-USetc.screen_w,screen_h— Physical screen dimensions in pixelssdk_type—google-imasdk_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 exampletrillboards.com. Only send this when Trillboards tells you to; it must match aninventorypartnerdomain=<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:
{
"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 thatvast_urlreturns — 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
completeevents rather than assuming the ad ended afterduration_secondselapsed. 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
/adspoll. We warn the advertiser at upload time when their creative exceeds the slot, so this should be rare — but do not assumeduration_secondsfits 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: 0is real and valid. A source we want to win outright is stampedpriority: 0— that includes the?test=1source, an injected first-party bid, and an auction winner. A player that treats0as "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, so11and13with 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
priorityascending. 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_msbounds one source's fetch+load.vast_waterfall.total_timeout_msbounds the whole loop. Honour both; when the total budget is gone, stop and return to content. degraded: truemeans 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_idandvariant_namecome from that source's entry, not fromsources[0]. The<Error>beacon (R5) fires per failed source, always. Theprogrammatic-eventminimum is unchanged — the source that played, plus oneno_fillfor the last source tried if none did (§7) — but a per-sourceerrorreport 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_urlonly. Indexes 0–3 were programmatic DSPs that returned no-fill for their geo; thetrillboards_directhouse 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:
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:
- Fetches the upstream partner VAST (Adipolo, VidVerto, etc.) using your device's IP via
X-Forwarded-Forfor geo-targeting - Follows
<VASTAdTagURI>wrapper chains up to 5 hops to resolve to inline creative - Injects Trillboards tracking pixels at the wrapper/inline level
- 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:
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:
ImpressionURLs on the first video frame render.startURLs immediately after the impression fire.- Quartile URLs (
firstQuartile,midpoint,thirdQuartile,complete) from the corresponding player lifecycle callbacks (timeupdate/endedin HTML5, equivalent events in other stacks). errorURLs on any load or playback failure, with[ERRORCODE]replaced by the numeric VAST/player error code — see §7.1.
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) — notfetch()— 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)
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:
// 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
// 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:
// 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:
- The content URI is empty, so it has no scheme.
- In media3,
Util.isLocalFileUri()returns true for any scheme-less URI — an empty URI looks exactly like a relative local path. - So the load is routed to
FileDataSource, which doesnew RandomAccessFile("", "r"). - That throws →
PlaybackExceptionwithERROR_CODE_IO_FILE_NOT_FOUND. - With no
onPlayerErrorhandler 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:
// 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:
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
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
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
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
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:
<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.
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:
errorCodeis 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.eventis signed, so anerrorbeacon can never be replayed as acomplete. 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 — 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:
# 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) ORingestion_path = 'proof_of_play'ORingestion_path = 'partner_sdk'(from yourprogrammatic-eventreport)request_idmatches the one from the waterfall source
Do not use
ingestion_path = 'impression_callback'as your success signal. Thatpath is guarded and deliberately excluded from the revenue rollup — rows carrying it
are not counted, so "I can see
impression_callbackrows" is not evidence thatanything settled.
vast_eventandproof_of_playare 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 — 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. 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.
"I'm getting fills but no completions"
Either:
- Your IMA SDK has
setAdMediaMimeTypes("video/mp4")set — remove it. - You're stripping the VAST wrapper before passing it to IMA — pass the full XML unmodified.
- Your device can't reach
api.trillboards.comto 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 namemodel= the actual hardware model; keep device serials and asset IDs in their identifier fieldsosv= Android release version ("11"or"13"); send the API level separately asdevice.android_api_level(30or33)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 and §6.1.
10. What's happening server-side (optional context)
When you GET a vast-wrap URL with pt=partner_api, our server does this:
- Fetches the upstream partner VAST (e.g., Adipolo's
pubads.adipolo.co/ctv/ad?...) with your device's IP inX-Forwarded-Forso DSPs geo-target correctly. - 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.
- 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. - 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 is our own player, and it is the reference implementation for everything in §0. 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:
- Pulls
errorCode+errorMessageoff the IMAAdErrorEvent. - Classifies the failure —
1009/303/ a message containingno fillorno ads vast responseis a no-fill; anything else is an error. (R2) - Fires the VAST
<Error>URL with[ERRORCODE]substituted for the real code, as akeepaliveGET. (R5) - Records the VAST request outcome (
no_fillvserror) with latency and source name. - Reports the
programmatic-eventwithrequest_id,variant_name,error_code,error_message. (R6) - Only resolves the caller when
_pendingWaterfallSourcesis empty — while sources remain, it advances to the next one instead of finishing. (R1 + R2) - 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
setAdMediaMimeTypesin this version) - Heartbeat every 60 seconds with full WebView UA
- VAST XML passed to IMA via
data:text/xmlURI (Media3 internally callssetAdsResponse) - Events reported via
programmatic-eventfor 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-corebundles CameraX 1.5.3, which embedsminAndroidGradlePluginVersion=8.0.0in its AAR metadata — AGP 4.x consumers will hard-fail atcheckDebugAarMetadata.ctv-measurementships 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):
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:
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:
<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.
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():
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:
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):
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:
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:
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:
<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. 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.
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:
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:
- HTTP heartbeat —
POST /openrtb/v1/heartbeatevery 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 authenticatedGET /v1/partner/device/{deviceId}/heartbeat(see API reference). - Real-time signals — Socket.io to
chat.trillboards.com, room joined automatically onstart(). 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) 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 nopickFirstand runs both. (If your app uses ONNX Runtime with R8, keepai.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:
- Download
sherpa-onnx-1.12.26.aarfrom the sherpa-onnx releases page (or contactengineering@trillboards.comfor the Trillboards-bundled copy with the matching native libs). - Drop it into your app's
libs/folder. - Register a
flatDirrepository so Gradle can resolve it:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
flatDir { dirs(file("app/libs")) }
}
}
- Add the implementation dep:
// app/build.gradle.kts
dependencies {
implementation(":sherpa-onnx-1.12.26@aar")
}
- Bundle the Moonshine model assets under
app/src/main/assets/moonshine-tiny/. The agent's runtime probe looks formoonshine-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— 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. - Embed SDK for partner CMS surfaces (web players, kiosk shells embedding Trillboards content):
docs/integrations/partner-embed-sdk.md. - Browser/CTV sensing without native Android:
npm i @trillboards/ctv-measurementfor browser-side BLE bridge consumers + Census enrichment + QR attribution. - Linux/Windows DOOH partners (no Android):
npm i @trillboards/edge-sdkfor 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:
- A screen_id that's actively requesting ads
- Your device's actual heartbeat body (the HTTP POST body, not a logcat sample)
- The full
vast_waterfallobject from one/adsresponse — the wholesources[]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. - The VAST response you got back from vast-wrap for one request (save the raw XML)
- IMA SDK error codes if playback fails (from the
AdErrorEventcallback) and anyPlaybackExceptionfromonPlayerError— they are different listeners and different failure classes (§6.1) - Your
programmatic-eventPOST 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.