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.
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 |
Every call into the SpeedTester service is authorised. Two mechanisms are available — whichever passes first is used.
Register your app's package name via the admin portal (Tenants → Allowed Packages). The service verifies the calling UID automatically — no code needed on your side.
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.
// 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")
}
<!-- AndroidManifest.xml -->
<queries>
<package android:name="tv.threess.speedtester" />
</queries>
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.// 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.
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
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)")
}
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}")
}
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
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.
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" |
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 |
| Code | Meaning |
|---|---|
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.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()
}
}
}
| 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 |
| 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 | 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.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 |
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 | 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. |