Tenants
| Name | Engine | Allowlist | Interval | Devices | Blocked | Actions |
|---|---|---|---|---|---|---|
| Loading… | ||||||
Admin Users
Loading…
SDK Doc
Open full pageSpeedTester SDK
Run speed tests, monitor network status, and manage Wi-Fi — all through the SpeedTester TV app, with a clean Kotlin API. No AIDL. No service binding. No boilerplate.
Overview
The SpeedTester SDK wraps the IPC layer between your app and the SpeedTester TV service. Under the hood it uses AIDL, but you never touch it — the SDK handles service binding, reconnection, and marshalling automatically.
| Requirement | Minimum |
|---|---|
| Android SDK | 24 (Android 7.0) |
| Kotlin | 1.9+ |
| Coroutines | 1.7+ |
| SpeedTester TV app | Installed on device |
Authentication
Every call into the SpeedTester service is authorised. Two mechanisms are available — whichever passes first is used.
📦 Package allowlist
Register your app's package name via this portal (Tenants → Allowed Packages). The service verifies the calling UID automatically — no code needed on your side.
🔑 Tenant API key
Request a key from 3SS and pass it to SpeedTesterSdk.init(). Useful for multi-app or white-label scenarios where package registration isn't practical.
Setup
Add the dependency
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven { url = uri("https://maven.3ss.tv/releases") }
}
}
// app/build.gradle.kts
dependencies {
implementation("tv.threess:speedtest-sdk:1.3.1")
}
Declare package visibility (Android 11+)
<!-- AndroidManifest.xml -->
<queries>
<package android:name="tv.threess.speedtester" />
</queries>
Initialise the SDK
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
SpeedTesterSdk.init(
context = this,
apiKey = "YOUR_TENANT_API_KEY" // omit if using package allowlist
)
}
}
init() throws an IllegalStateException at runtime.Wake the SpeedTester app (SDK 1.2.0)
// right after init(), once per app start
SpeedTesterSdk.wakeInBackground(this)
// or, from a coroutine: returns false (never throws) if the app is unreachable
val awake = SpeedTesterSdk.wake(context)
Android keeps a freshly installed app stopped until one of its components runs, and a stopped app receives no BOOT_COMPLETED. Until someone opened the SpeedTester UI once it therefore never started on boot and had no tenant configuration when your first call arrived. Binding to it fixes both: the platform clears the stopped state, and from app 1.2.0 the services bootstrap themselves on bind. wake holds the binding for 20 s so that completes, then releases. Every other SDK call has the same side effect; wake just moves the cold start to your startup instead of the user's first speed test.
Speed test
Use SpeedTestClient to trigger a test and observe progress as a Flow. Cancelling the coroutine automatically cancels the test. The service runs one measurement at a time: a startTest() while a test is already in flight joins it and receives the same result, and a single engine run is capped at 90 s, after which the flow ends with an Error event.
val client = SpeedTestClient(context)
client.startTest().collect { event ->
when (event) {
is SpeedTestClient.Event.Progress -> showProgress(event.downloadMbps, event.percent)
is SpeedTestClient.Event.Completed -> showResult(event.result)
is SpeedTestClient.Event.Error -> showError(event.message)
}
}
client.release() // call in onDestroy / onCleared
Network status
Observe live network conditions as a Flow. Emits immediately with the current state, then on every change.
val client = NetworkStatusClient(context)
client.networkStatus().collect { status ->
println("${status.connectionType} — ${status.ssid} (${status.signalStrengthDbm} dBm)")
}
Wi-Fi
val client = WifiClient(context)
when (val result = client.connect("MyNetwork", "password123")) {
is WifiConnectResult.Connected -> println("Connected to ${result.ssid}")
is WifiConnectResult.Failed -> println("Failed: ${result.reason}")
}
Connection quality
ConnectionQualityClient is the one-call source for a connection status card: it grades the last speed test against the live network and returns a ConnectionQuality — headline grade, the dominant issue, the numbers behind it, per-resolution streaming status (480p→4K: available / unstable / unavailable), suggested actions, and isStale. Everything is enums — your app owns all copy and localisation.
val client = ConnectionQualityClient(context)
// Render from the last test, no waiting; null = no test has run yet
val quality = client.get()
// Or measure fresh, streaming progress into the card
client.measure().collect { event ->
when (event) {
is ConnectionQualityClient.Event.Progress -> showProgress(event.percent)
is ConnectionQualityClient.Event.Ready -> renderCard(event.quality)
is ConnectionQualityClient.Event.Failed -> showError(event.message)
}
}
client.release()
Composes the network status and speed test services — no new IPC surface, so it works against any SpeedTester app build that serves those two. The same verdict is available to AI consumers as the quality.summary signal.
Handling failures
Every client call fails fast — it either returns or throws a SpeedTesterException; nothing blocks waiting for a service that will not arrive. Handle the three subtypes at your integration boundary:
try {
val snapshot = client.getSnapshot()
} catch (e: SpeedTesterException.AppMissing) {
// SpeedTester app not installed (or service unresolvable) — prompt to install
} catch (e: SpeedTesterException.BindTimeout) {
// installed but did not bind within 5 s — retry later
} catch (e: SpeedTesterException.NotAuthorised) {
// not on the allowlist and no valid API key — fix tenant config
}
Transient IPC noise (dead binder, malformed payload) is degraded to null / empty results instead of an exception. If you use NetworkAiBridge, you don't catch anything — these surface as the app_missing / bind_timeout / not_authorised error codes below.
AI integration — NetworkAiBridge
NetworkAiBridge exposes the whole SDK as three generic JSON-in / JSON-out functions designed for LLM tool-calling (e.g. an AI assistant in your launcher). Register them as tools with your model — the bridge handles capability discovery, batched signal reads and action dispatch on top of the typed clients, and every return value is a JSON string you can feed straight back to the model.
val bridge = NetworkAiBridge(context)
// 1. Tell the model what it can see and do
val manifest = bridge.getAdvancedNetworkContext()
// 2. Read the signals the model asked for
val values = bridge.getNetworkSignals(listOf("wifi.signalDbm", "speedtest.downloadMbps"))
// 3. Execute an action the model chose
val result = bridge.networkAction("wifi.connect", mapOf("ssid" to "MyAp", "passphrase" to "secret"))
bridge.release() // call in onDestroy / onCleared
getAdvancedNetworkContext()
Returns a manifest of every signal id and action id (with parameter schemas and an available flag — Wi-Fi actions are marked unavailable on devices without a Wi-Fi radio), plus a live network snapshot and the last speed test result so the model has immediate context without a follow-up call.
getAdvancedNetworkContext() is generated at runtime from the SDK's registry and is always authoritative. The signal and action tables below are a reference snapshot — if they ever disagree with the manifest, trust the manifest.getNetworkSignals(signalIds)
Batch-reads signal values by id. Returns {"signals": {id: value}, "errors": {id: message}}. Unknown ids land in errors; a signal with no value yet (e.g. no speed test has run) is null.
| Signal id | Type | Description |
|---|---|---|
net.connectionType | string | WIFI, CELLULAR, ETHERNET, UNKNOWN |
net.ipv4 / net.ipv6 | string | Device IP addresses on the active network |
wifi.ssid / wifi.bssid | string | Connected network name / AP MAC address |
wifi.signalDbm | int | Signal strength in dBm (more negative = weaker) |
wifi.frequencyMhz | int | Channel frequency (2.4 GHz vs 5 GHz band) |
wifi.linkSpeedMbps | int | Negotiated PHY link speed |
wifi.visibleSsids | string[] | SSIDs currently visible to the device |
wifi.scanResults | object[] | Detailed scan: ssid, bssid, level, frequencyMhz, capabilities, isOpen, isKnownNetwork |
speedtest.lastResult | object | Full last result including the network it ran on |
speedtest.downloadMbps / speedtest.uploadMbps | float | Throughput of the last test |
speedtest.pingMs / speedtest.jitterMs | float | Latency / jitter of the last test |
speedtest.packetLossPercent | float | Packet loss of the last test |
speedtest.timestampEpochMs | long | When the last test completed |
speedtest.engine | string | "ookla" or "librespeed" |
quality.summary | object | Graded verdict: grade (OFFLINE/POOR/FAIR/GOOD/EXCELLENT), dominant issue, per-resolution streaming status, suggested actions, isStale. Null until a speed test has run |
networkAction(actionId, params)
Dispatches an action by id with string key-value params. Returns {"ok": true, "result": …} on success or {"ok": false, "code": …, "error": …} on failure.
| Action id | Params | Description |
|---|---|---|
speedtest.run | — | Runs a full speed test; suspends until finished (typically 15–60 s, never more than 120 s) and returns the result. If a test is already in progress it joins that test instead of starting a second one. Cancel the calling coroutine to abort. |
wifi.scan | — | Triggers a Wi-Fi scan and returns visible access points |
wifi.connect | ssid, passphrase | Connects to a network with a passphrase |
wifi.connectKnown | ssid | Reconnects to a known network; fails with needs_passphrase if one is required again |
wifi.forget | ssid | Removes a network from the known-networks store |
wifi.disconnect | — | Disconnects from the current Wi-Fi network |
Error codes
| Code | Meaning |
|---|---|
app_missing | SpeedTester app is not installed, or the service could not be resolved or bound — the SDK fails fast instead of waiting |
bind_timeout | Service was resolved but did not bind within 5 s — app installed but not yet launched, or stuck |
not_authorised | Caller is not on the package allowlist and supplied no valid API key |
unknown_action | Action id not recognised (valid ids are listed in the error message) |
missing_params | A required param was absent or empty |
unavailable | Device has no Wi-Fi radio, so Wi-Fi actions cannot run |
needs_passphrase | Network requires a passphrase — the model should ask the user, then retry with wifi.connect |
connect_failed / forget_failed | The Wi-Fi operation was rejected by the system (reason included) |
speedtest_failed | The engine reported an error mid-test, including the service's own 90 s engine timeout |
speedtest_timeout | No result within 120 s (bridge safety net). Do not retry immediately — fall back to quality.summary / speedtest.lastResult |
action_failed | Unexpected failure (service unreachable, binder error, …) |
speedtest.run is the only long-running action. If your assistant needs a responsive UI while it runs, launch the tool call in its own coroutine and keep streaming getNetworkSignals reads in parallel. If the user cancels the assistant turn, cancel that coroutine too so the test is not left running with nobody listening. You do not need your own timeout or "already running" guard — the bridge always returns within 120 s and concurrent calls join the in-flight test.Live progress — speedTestEvents
networkAction("speedtest.run") only returns when the test is over, but the bridge also exposes speedTestEvents: SharedFlow<SpeedTestClient.Event> so your UI can show the numbers while they are being measured. Collect it from the same NetworkAiBridge instance you hand the tool calls to: you get Started once with the engine ("ookla" / "librespeed" — pick the "Powered by" logo here; SpeedTester app 1.3.0+), then Progress roughly once a second (download Mbps during the download phase, upload Mbps during the upload phase, percent capped at 99), then exactly one Completed or Error. A bridge timeout arrives as an Error too, so the collector never hangs. The most recent event is replayed to new collectors — treat a replayed terminal event as "last known result", not as a running test.
// once, when the assistant session starts
scope.launch {
bridge.speedTestEvents.collect { event ->
when (event) {
is SpeedTestClient.Event.Started -> widget.showPoweredBy(event.engine)
is SpeedTestClient.Event.Progress -> widget.showMeasuring(event.downloadMbps, event.uploadMbps)
is SpeedTestClient.Event.Completed -> widget.showResult(event.result)
is SpeedTestClient.Event.Error -> widget.showLastKnown()
}
}
}
Data models
SpeedTestResult
| Field | Type | Description |
|---|---|---|
sessionId | String | Unique test identifier |
engine | String | "ookla" or "librespeed" |
timestampEpochMs | Long | Completion time (Unix ms) |
downloadMbps | Float | Download speed |
uploadMbps | Float | Upload speed |
pingMs | Float | Latency |
jitterMs | Float | Jitter |
packetLossPercent | Float | Packet loss % |
network | NetworkStatus | Network context at test time |
NetworkStatus
| Field | Type | Description |
|---|---|---|
connectionType | ConnectionType | WIFI, CELLULAR, ETHERNET, UNKNOWN |
ssid | String? | Wi-Fi SSID. Null off Wi-Fi or when the platform withholds it (the SpeedTester app needs location granted; never the raw <unknown ssid> sentinel) |
bssid | String? | Access point MAC address; null when withheld |
signalStrengthDbm | Int | Signal in dBm |
linkSpeedMbps | Int | PHY link speed |
ipAddress | String? | IPv4 address |
visibleSsids | List<String> | Nearby network names |
Error reference
| Error | Cause | Fix |
|---|---|---|
"Not authorised" |
Package not on allowlist; API key missing or invalid | Add package in Tenants → Allowed Packages, or pass a valid API key |
IllegalStateException: init()… |
Client created before SpeedTesterSdk.init() |
Move init() to Application.onCreate() |
SpeedTesterException.AppMissing |
SpeedTester app not installed, or its service could not be resolved or bound | Install the SpeedTester app and add the <queries> entry from step 2 |
SpeedTesterException.BindTimeout |
Service resolved but did not bind within 5 s — usually a slow cold start of the SpeedTester process | Call SpeedTesterSdk.wake() at startup so the cold start happens before your first real call; the next call re-binds |
First test after a fresh install fails |
SpeedTester app older than 1.2.0 had no tenant config until its UI was opened once | Update the SpeedTester app to ≥ 1.2.0 and call wake() at startup |
SpeedTesterException.NotAuthorised |
Package not on allowlist; API key missing or invalid | Add package in Tenants → Allowed Packages, or pass a valid API key |
Permissions
All sensitive permissions are held by the SpeedTester app. Your AndroidManifest.xml does not need ACCESS_FINE_LOCATION, ACCESS_WIFI_STATE, CHANGE_WIFI_STATE, or INTERNET. You only need the <queries> entry from step 2.
Wi-Fi name (SSID) availability. Android 10+ only discloses the SSID and BSSID to a process that is foreground-for-location. From SDK 1.1.1 the binding passes your app's foreground capabilities to the SpeedTester process (BIND_INCLUDE_CAPABILITIES), so NetworkStatus.ssid is populated while your app is on screen. It is null (never a raw <unknown ssid>) when the SpeedTester app has no location permission, location services are off, or the read happened with no client on screen. Signal strength, frequency and link speed are unaffected.
Version history
| Version | Date | Changes |
|---|---|---|
1.3.1 | 2026-09-23 | No SDK API change: version kept in step with the SpeedTester app. App 1.3.1 fixes a crash on every launch on Android 14 (UninitializedPropertyAccessException: workerFactory): WorkManager is now resolved lazily and its configuration reads the worker factory from a Hilt entry point. |
1.3.0 | 2026-09-23 | SpeedTestClient.Event.Started(sessionId, engine) (also on NetworkAiBridge.speedTestEvents and as ConnectionQualityClient.Event.Started(engine)): the engine — "ookla" or "librespeed" — is announced before the first Progress, so you can show the right "Powered by" logo from the start. New AIDL callback onStarted, appended last so mixed versions keep working; an older SpeedTester app never sends it, so fall back to result.engine. Exhaustive when (event) blocks need a Started branch. Pairs with SpeedTester app 1.3.0, which also uploads SDK-triggered results with aggregate network context for the portal’s Network Analytics. |
1.2.0 | 2026-09-16 | SpeedTesterSdk.wake() / wakeInBackground() and NetworkAiBridge.wake(): bind once at host-app start so a never-launched SpeedTester install leaves Android's stopped state (arming BOOT_COMPLETED) and bootstraps its tenant config. Pairs with SpeedTester app 1.2.0, whose services bootstrap on bind, wait for the first config fetch before running a test, and persist caller allowlists across process restarts. |
1.1.1 | 2026-09-09 | Version jumps from 1.0.2 to match the SpeedTester app, so one number identifies a compatible pair. Binding uses BIND_INCLUDE_CAPABILITIES so SSID/BSSID are no longer redacted while the host app is on screen. SpeedTester app 1.1.1 pairs with it: redaction sentinels map to null, and connectionType now follows the SDK order (Ethernet was previously reported as CELLULAR). |
1.0.2 | 2026-09-02 | NetworkAiBridge.speedTestEvents republishes live progress of speedtest.run. |
1.0.1 | 2026-09-02 | speedtest.run capped at 120 s (speedtest_timeout); concurrent startSpeedTest calls join the in-flight test. |
1.0.0 | — | Initial release. |