ScanFilter.setDeviceAddressがIllegalArgumentExceptionで落ちる
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Android の ScanFilter.Builder().setDeviceAddress() は、JSON null が optString で文字列"null"に化けた値を渡されると IllegalArgumentException を投げ、呼び出し元の Service を丸ごと落とす。
結論
Android の ScanFilter.Builder().setDeviceAddress() は、JSON null が optString で文字列"null"に化けた値を渡されると IllegalArgumentException を投げ、呼び出し元の Service を丸ごと落とす。 サーバが欠落値のつもりで送った null は、Android の JSONObject.optString() を経由すると空文字ではなく文字列 "null" になる。これを Kotlin の isNotBlank() ガードは「空白ではない」という理由で素通りさせ、そのまま永続化された不正なMACアドレスが、次にBLEスキャンを開始する瞬間にクラッシュを引き起こす。
症状
サイネージ専用機(BLEセンサーを設置していない構成)で、BleService が起動直後にクラッシュする事象があった。設定配信APIが target_mac(BLEでスキャンする対象デバイスのMACアドレス)を null として配信した端末で再現した。
target_mac は Config(SharedPreferences)に永続化される値で、一度書き込まれると起動のたびに読み直される。クラッシュは一度きりではなく、壊れた値を読み直すたびに同じ場所で再現する。
原因
キミテラス v2 のサーバは、設定配信APIのレスポンスで target_mac が未設定のとき null をそのまま返していた。
// 修正前(lp-compat.ts)
export type LpConfigResponse = {
version: number;
config: {
target_mac: string | null;
// ...
} | null;
// ...
};
// ...
return {
// ...
config: {
target_mac: result.config.targetMac, // null をそのまま返す
webhook_url: result.config.webhookUrl,
signage_url: result.config.signageUrl,
// ...
},
};
実機 tvbridge(旧APK)はこの値を JSONObject.optString("target_mac") で読む。Android の仕様では、キーの値が JSON null のとき optString は空文字ではなく文字列 "null" を返す。 素朴な欠落チェックとして書かれていた次のガードは、この「"null" という4文字の文字列」を想定していなかった。
// 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 の isNotBlank() は「空白文字だけの文字列でないか」しか見ない。"null" は空白ではないので isNotBlank() は true を返し、ガードをそのまま素通りする。素通りした値は 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()
}
書き込み時にも読み出し時にも .uppercase() が掛かるため、"null" は SharedPreferences に "NULL" として永続化される。
BLEスキャンを開始する startScan() は、この永続化された targetMac をそのまま ScanFilter に渡す。
// BleService.startScan(抜粋)
private fun startScan() {
// ...
val filter = ScanFilter.Builder()
.setDeviceAddress(targetMac) // targetMac == "NULL"
.build()
// ...
try {
scanner.startScan(listOf(filter), settings, scanCallback)
// ...
} catch (e: Throwable) {
// ...
}
}
ScanFilter.setDeviceAddress() は有効なMACアドレス形式を要求するAPIで、"NULL" のような不正な文字列を渡すと IllegalArgumentException を投げる。この ScanFilter.Builder() の呼び出しは try ブロックより前にあり、startScan() を呼び出す BleService.onCreate() 自体にも例外処理が無い。結果として、例外は誰にも捕まらずに Service の起動を貫通し、BleService 全体がクラッシュする。
他の2つのフィールド(webhook_url / signage_url)も同じ optString + isNotBlank() のガードを通る以上、同じように "null" をすり抜けさせていた。しかし webhook_url が渡る先は OkHttp の Request.Builder().url() で、その呼び出しは try/catch の中にある。
// Uploader(抜粋)
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
}
失敗してもログに残るだけで、Service は落ちない。target_mac だけが、検証の厳しい ScanFilter のAPIに、例外処理の無い経路で渡っていた。
直し方
直したのはサーバ側だけで、実機のAPKには手を入れていない。toLpConfigResponse で target_mac / webhook_url / signage_url の null を空文字へ畳んでから返すようにした。
// 修正後(lp-compat.ts)
target_mac: result.config.targetMac ?? "",
webhook_url: result.config.webhookUrl ?? "",
signage_url: result.config.signageUrl ?? "",
空文字なら JSONObject.optString() はそのまま空文字を返し、isNotBlank() のガードが想定通りに機能してスキップする。端末側に既にあったガードを「正しく機能させる」だけで直るため、実機の操作や再配布は不要だった。
あわせて、レスポンスの型もこの3フィールドだけ string | null から string へ締めた。
// 修正前: target_mac: string | null;
// 修正後: target_mac: string;
再発防止
この3フィールドの型を string に締めたことで、将来誰かが result.config.targetMac をそのまま返すコードに書き戻しても、コンパイル時に型エラーとして検出される。null を返す経路そのものを型で塞いだ形になる。
よくある質問
Q1なぜ isNotBlank() のガードは文字列"null"を弾けなかったのですか?
Kotlin の isNotBlank() は「空白文字だけの文字列でないか」を見るだけです。"null"という4文字は空白ではないため isNotBlank() は true を返し、ガードをそのまま素通りしてしまいます。
Q2なぜ webhook_url や signage_url は同じ処理で落ちなかったのですか?
target_mac だけが ScanFilter.setDeviceAddress という検証の厳しいAPIに渡るからです。webhook_url は OkHttp の Request.Builder().url() に渡りますが、その呼び出しは try/catch の中にあり、失敗しても1回分のPOST失敗としてログに残るだけで Service 自体は落ちません。
Q3この不具合は実機のAPKを更新しないと直りませんか?
いいえ。直したのはサーバ側(lp-compat.ts)だけです。null の代わりに空文字を返すようにしたことで、端末側に既にあった isNotBlank() のガードが正しく機能するようになり、実機の操作なしに直りました。
確認した環境
- Kotlin + org.json.JSONObject(Android標準)/ compileSdk 34 / minSdk 26 (Android 8.0) / targetSdk 34 — tv-ble-bridge
- キミテラス v2 (Next.js/TypeScript) apps/web — 2026-06-13 に本番修正(PR #855)
この記事の根拠
- TypeScriptファイル 81〜114行目コミット d5196b9
- TypeScriptファイル 81〜124行目コミット dfc0c5a
- Kotlinファイル 201〜222行目コミット 8de64d6
- Kotlinファイル 79〜134行目コミット 8de64d6
- Kotlinファイル 228〜253行目コミット 8de64d6
- Kotlinファイル 141〜150行目コミット 8de64d6
- Kotlinファイル 37〜46行目コミット 8de64d6
- ファイル 9〜14行目コミット 8de64d6
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。