STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Android Bluetooth接続でパスキー確認ダイアログが表示されない問題の解決

こどもの夏休みはもう終わってしまいましたが、自由研究の宿題として貯金箱を作りたい、というので一緒になって作りました。こどもの作業を手伝ってしまっては宿題にならないので、私も横で同じものを作って、つまずきポイントを繰り返し見せながらなんとか完成までこぎつけました。久しく厚紙工作をやっていませんでしたが、改めて面白さを実感したDIYでした。

こんにちは。 STORES 決済 でAndroidアプリエンジニアをしている Yamaton です。 今回は、AndroidアプリのBluetooth接続機能で遭遇した問題について書きたいと思います。 特定のデバイスでのみペアリングのパスキーダイアログが表示されず、接続に失敗するという現象です。

OS標準の設定アプリからは問題なく接続できるのに、なぜか自作アプリからだと失敗する…。これは、ペアリング処理の呼び出し漏れという単純ながらも見落としがちな落とし穴でした。 この記事では、この問題の発見から原因分析、そして解決に至るまでのプロセスを、具体的なコードを交えて解説します。

どんな問題だったか

まずは、実際に発生していた現象を整理します。

  • 現象:アプリ内の接続フローで、特定のBluetoothデバイス(以降、デバイスBとします)をタップしても、パスキー入力を求めるダイアログが表示されず、最終的に接続がタイムアウトしてしまう。
    • デバイスA → 正常にダイアログが表示され、接続成功 ✅
    • デバイスB → ダイアログが表示されず、接続失敗 ❌
  • 切り分け:Android OSの「設定 > Bluetooth」からデバイスBに接続を試みると、正常にダイアログが表示され、ペアリングできる。

このことから、問題はデバイスやOSではなく、アプリ側の実装にあることは明らかでした。同じコードを通っているはずなのに、なぜデバイスによって挙動が変わるのか? というのが最初の謎でした。

原因は「ペアリング」と「接続」の混同にあった

結論から言うと、原因はペアリング (Bonding)接続 (Connection) を区別せず、いきなり接続処理を呼び出していたことでした。

Bluetooth接続の2ステップ

この問題を理解するには、Bluetooth接続が大きく分けて2つのステップで構成されていることを認識する必要がありました。

  1. ペアリング (Bonding)

    • デバイス同士が初めて認証しあう、「顔合わせ」のステップ。パスキーの交換はここで行われます。
    • Androidでは BluetoothDevice.createBond() を呼び出すことで、このプロセスを明示的に開始します。
  2. 接続 (Connection)

    • ペアリング済みのデバイス間で、データ通信を確立するステップ。
    • BluetoothSocket.connect() などを使って行います。

決済アプリは今年で10年目をむかえます。既存の実装では、デバイスが未ペアリング状態かどうかを考慮せず、いきなり2番の「接続」処理を呼び出していました。親切なデバイス(以降、デバイスAとします)は、接続要求を受けるとOSが暗黙的にペアリングプロセスを開始してくれたのですが、真面目なデバイスBは、「知らない人(未ペアリング)とは話せません」と、接続を拒否していたわけです。

これが、今回の問題の根本原因でした。

解決策:ペアリング状態をチェックして、ないなら要求する

原因がわかれば、あとは実装あるのみです。具体的な方針とコードを見ていきましょう。

設計方針

  1. 接続要求が来たら、まずデバイスのペアリング状態 (bondState) を確認する。
  2. ペアリング済み (BOND_BONDED) なら、そのまま接続処理に進む。
  3. 未ペアリング (BOND_NONE) なら、createBond() を呼び出してペアリングを要求する。
  4. ペアリングの完了を BroadcastReceiver で検知してから、接続処理を開始する。

ペアリング状態の確認

まず、メインの処理フロー。ペアリング状態によって処理を分岐させます。

private fun startPairing(device: BluetoothDevice?) {
    // ... (nullチェックなどの事前処理)

    // 1. ペアリング状態を確認
    if (device.bondState == BluetoothDevice.BOND_BONDED) {
        // 2. ペアリング済みなら、直接接続処理へ
        startDeviceConnection(device)
    } else {
        // 3. 未ペアリングなら、ペアリングを要求
        startDeviceBonding(device)
    }
}

ペアリングの要求と結果の待受

startDeviceBonding() でペアリングを要求し、その結果を BroadcastReceiver で待ち受けます。

// ペアリングを要求する
private fun startDeviceBonding(device: BluetoothDevice?) {
    try {
        device?.createBond()
    } catch (e: SecurityException) {
        // 例外処理
    }
}

// BroadcastReceiverでペアリング結果を監視
override fun onReceive(context: Context, intent: Intent) {
    if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
        when (intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)) {
            BluetoothDevice.BOND_BONDED -> {
                // 4. ペアリングが成功! 接続処理を開始する
                startDeviceConnection(device)
            }
            BluetoothDevice.BOND_NONE -> {
                // ペアリング失敗または解除時の処理
                handleBondingFailure()
            }
        }
    }
}

接続処理

ペアリングが完了したら次は接続です。BluetoothSocket.connect() はUIスレッドをブロックするため、必ずワーカースレッドで実行します。

// Kotlin Coroutinesを使った接続処理の例
private fun startDeviceConnection(device: BluetoothDevice) {
    viewModelScope.launch(Dispatchers.IO) { // ワーカースレッドに切り替え
        try {
            // SPPのUUIDでソケットを作成
            val uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
            val socket = device.createRfcommSocketToServiceRecord(uuid)
            socket.connect() // ブロッキングする可能性のある処理
            
            // 接続成功
            withContext(Dispatchers.Main) {
                listener.onConnectionEstablished(socket)
            }
        } catch (e: Exception) {
            // 例外処理(接続失敗)
        }
    }
}

これでペアリング状態を意識した正しいフローで接続を試みることができるようになりました。

実装で注意すべきポイント

今回の実装にあたり、いくつか注意すべき点がありました。

  • BLUETOOTH_CONNECT 権限: Android 12 (API 31) 以降、createBond()connect() の呼び出しには BLUETOOTH_CONNECT 権限が必要です。SecurityExceptiontry-catch は忘れないようにしましょう。
  • BroadcastReceiver のライフサイクル管理: ACTION_BOND_STATE_CHANGED を受け取る BroadcastReceiver は、画面が表示されている間だけ有効になるよう、onResume で登録し onPause で解除するのが定石です。
  • 非同期処理の考慮: ペアリングは非同期で、いつ完了するか分かりません。処理中にユーザーが画面を離れる可能性も考慮し、フラグ管理や currentDevice のクリアなどを適切に行う必要があります。
  • socket.connect()の前に、bluetoothAdapter.cancelDiscovery()を呼んでおく。接続試行前にデバイス検出の停止をすることで接続が遅くなったり失敗したりするのを回避できます。 Bluetoothデバイスの接続  |  Connectivity  |  Android Developers

まとめ

今回は、Bluetooth接続におけるパスキーダイアログが表示されない問題と、その解決策について解説しました。

今回の学びをまとめると、以下のようになります。

  1. ペアリングと接続は別物。いきなり接続しようとせず、まずはペアリング状態を確認する。
  2. 未ペアリングのデバイスには、createBond() を明示的に呼び出してペアリングを要求する。
  3. ペアリングの結果は非同期で返ってくるため、BroadcastReceiver で完了を検知してから次の処理に進む。
  4. socket.connect() のような重い処理は、必ずワーカースレッドで行う。

一見複雑に見えるBluetooth接続ですが、ステップを一つ一つ丁寧に実装すれば、安定した機能をユーザーに提供できるはずです。この記事が、同じような問題でハマっている誰かの助けになれば幸いです。

参考資料