ScanFilter.setDeviceAddress Throws on the String "null"
This article may contain affiliate links. Its content is not affected by advertising.
In short
Android's ScanFilter.Builder().setDeviceAddress() throws IllegalArgumentException when handed a JSON null that optString turned into the literal string "null", crashing the calling Service entirely.
Conclusion
Android’s ScanFilter.Builder().setDeviceAddress() throws IllegalArgumentException when handed a JSON null that optString turned into the literal string "null", crashing the calling Service entirely. A null the server sent to mean “no value” becomes, once it passes through Android’s JSONObject.optString(), not an empty string but the four-character string "null". Kotlin’s isNotBlank() guard lets it through because it “is not blank,” and the invalid MAC address that gets persisted from it crashes the app the instant a BLE scan next starts.
Symptom
On signage-only devices (configured without a BLE sensor installed), BleService crashed right after startup. It reproduced on any device where the config-distribution API had delivered target_mac (the MAC address of the BLE device to scan for) as null.
target_mac is persisted into Config (SharedPreferences): once written, it is read back on every startup. The crash was not a one-off — it recurred at the same spot every time the corrupted value was read back.
Cause
Kimiterrace v2’s server returned null as-is in the config-distribution API response whenever target_mac was unset.
// Before the fix (lp-compat.ts)
export type LpConfigResponse = {
version: number;
config: {
target_mac: string | null;
// ...
} | null;
// ...
};
// ...
return {
// ...
config: {
target_mac: result.config.targetMac, // returns null as-is
webhook_url: result.config.webhookUrl,
signage_url: result.config.signageUrl,
// ...
},
};
The physical tvbridge device (the older APK) reads this value with JSONObject.optString("target_mac"). Under Android’s own behavior, when a key’s value is a JSON null, optString returns not an empty string but the literal string "null". The following guard, written as a simple missing-value check, never anticipated this four-character string "null".
// ConfigPoller.applyConfigFields
cfg.optString("target_mac").takeIf { it.isNotBlank() }?.let { mac ->
if (mac != Config.targetMac(context)) {
Config.setTargetMac(context, mac)
Log.i(TAG, "target_mac updated -> $mac")
}
}
Kotlin’s isNotBlank() only checks “is this string made up of whitespace alone.” "null" is not whitespace, so isNotBlank() returns true and the guard lets it straight through. The value that gets through is written via Config.setTargetMac().
// Config.setTargetMac / Config.targetMac
fun setTargetMac(context: Context, mac: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
putString(KEY_TARGET_MAC, mac.uppercase())
}
}
fun targetMac(context: Context): String {
val p = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return p.getString(KEY_TARGET_MAC, BuildConfig.DEFAULT_TARGET_MAC)!!.uppercase()
}
Because .uppercase() is applied on both write and read, "null" ends up persisted in SharedPreferences as "NULL".
startScan(), which starts the BLE scan, passes this persisted targetMac straight into ScanFilter.
// BleService.startScan (excerpt)
private fun startScan() {
// ...
val filter = ScanFilter.Builder()
.setDeviceAddress(targetMac) // targetMac == "NULL"
.build()
// ...
try {
scanner.startScan(listOf(filter), settings, scanCallback)
// ...
} catch (e: Throwable) {
// ...
}
}
ScanFilter.setDeviceAddress() requires a valid MAC address format, and throws IllegalArgumentException when handed an invalid string like "NULL". This call to ScanFilter.Builder() sits before the try block, and BleService.onCreate(), which calls startScan(), has no exception handling of its own around it either. As a result, nothing catches the exception, it propagates straight through the Service’s startup, and BleService crashes entirely.
The other two fields (webhook_url / signage_url) go through the same optString + isNotBlank() guard, so they let "null" through in exactly the same way. But webhook_url is passed into OkHttp’s Request.Builder().url(), and that call sits inside a try/catch.
// Uploader (excerpt)
val ok = try {
val req = Request.Builder()
.url(webhookUrl)
.post(payload.toRequestBody(JSON_MEDIA_TYPE))
.build()
httpClient.newCall(req).execute().use { resp -> resp.isSuccessful }
} catch (e: Throwable) {
Log.d(TAG, "POST failed: ${e.javaClass.simpleName}: ${e.message}")
false
}
A failure there is only logged; the Service does not go down. Only target_mac was routed, with no exception handling along the way, into the strict-validation ScanFilter API.
The fix
Only the server side was changed; the physical APK was not touched. toLpConfigResponse was changed to fold null into an empty string for target_mac / webhook_url / signage_url before returning them.
// After the fix (lp-compat.ts)
target_mac: result.config.targetMac ?? "",
webhook_url: result.config.webhookUrl ?? "",
signage_url: result.config.signageUrl ?? "",
For an empty string, JSONObject.optString() returns the empty string as-is, and the isNotBlank() guard now works as originally intended and skips it. Because this just makes the guard already present on the device work correctly, no change to the physical devices or redistribution of the APK was needed.
Alongside this, the response type for these three fields was also tightened from string | null to string.
// Before: target_mac: string | null;
// After: target_mac: string;
Preventing a repeat
Tightening these three fields’ type to string means that if someone in the future writes code that returns result.config.targetMac as-is again, it is now caught as a compile-time type error. The path that could return null has been closed off by the type itself.
Frequently asked questions
Q1Why didn't isNotBlank() catch the string "null"?
Kotlin's isNotBlank() only checks whether a string is made up of whitespace. The four characters "null" are not whitespace, so isNotBlank() returns true and the guard lets the value straight through.
Q2Why didn't webhook_url or signage_url crash through the same path?
Only target_mac is passed into ScanFilter.setDeviceAddress, a strict-validation API. webhook_url goes into OkHttp's Request.Builder().url(), and that call sits inside a try/catch, so a failure there is just logged as one failed POST and never brings down the Service.
Q3Did this require updating the APK on the physical devices?
No. Only the server side (lp-compat.ts) was changed. Returning an empty string instead of null let the isNotBlank() guard already on the device work as intended, so the fix shipped with no change to the devices themselves.
Environment verified
- Kotlin + org.json.JSONObject (Android standard) / compileSdk 34 / minSdk 26 (Android 8.0) / targetSdk 34 — tv-ble-bridge
- Kimiterrace v2 (Next.js/TypeScript) apps/web — fixed in production on 2026-06-13 (PR #855)
What this article is based on
- TypeScript file lines 81-114commit d5196b9
- TypeScript file lines 81-124commit dfc0c5a
- Kotlin file lines 201-222commit 8de64d6
- Kotlin file lines 79-134commit 8de64d6
- Kotlin file lines 228-253commit 8de64d6
- Kotlin file lines 141-150commit 8de64d6
- Kotlin file lines 37-46commit 8de64d6
- file lines 9-14commit 8de64d6
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.