Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

ScanFilter.setDeviceAddressがIllegalArgumentExceptionで落ちる

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

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

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。