ソースを参照

fix: harden Bluetooth OBD path against freezes

Move all Bluetooth binder work to a serialized native worker, filter the OBD-II picker to likely adapters, bound connection attempts, stop relisting bonded devices on every resume, and add bridge timeouts. Bump to 1.0.6+18 and add the universal release APK.
Krasimir Kazakov 6 日 前
親
コミット
2eac7d1d67

+ 5 - 1
AGENTS.md

@@ -102,7 +102,11 @@ notifyListeners();
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
-- When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings** and refreshes paired devices on app resume; pairing happens in Android settings, never in CarDash.
+- All Bluetooth binder work (`bluetoothState`, `listBondedDevices`, `connectObd`, `disconnectObd`) runs on `TelemetryBridge`'s serialized `cardash-bluetooth` worker; never move it back to the MethodChannel/main thread.
+- The OBD-II picker lists likely OBD-II adapters first; **Show all paired devices** is the explicit escape hatch for unusual adapters. Phones and headsets stay out of the default list.
+- Connection attempts are bounded (three failures, 12 s connect watchdog). After they are exhausted the card shows **Retry connection**; do not restore an infinite reconnect loop.
+- When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings**. On resume the controller checks adapter power state and reuses the cached bonded list; it only lists devices when the list is unknown, after **Find**, or when the card opens.
+- Pairing happens in Android settings, never in CarDash.
 - The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Playback uses Android media keys; title/progress need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.
 

+ 15 - 8
README.md

@@ -18,7 +18,7 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 ## Download
 
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
-- [cardash-android-1.0.5.apk](releases/cardash-android-1.0.5.apk) — universal Android release
+- [cardash-android-1.0.6.apk](releases/cardash-android-1.0.6.apk) — universal Android release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
@@ -239,10 +239,15 @@ the app resumes.
   Nothing playing state.
 
 **OBD-II**
-- Adapter dropdown: None plus paired Bluetooth Classic devices.
+- Adapter dropdown: None plus paired Bluetooth Classic devices that look like
+  OBD-II adapters. **Show all paired devices** reveals phones, headsets and
+  other bonded devices for unusual adapters.
 - Connection status and a Find/Allow action for Bluetooth permission.
-- If Bluetooth is off: **Open Bluetooth settings** to turn it on, then return
-  and the paired-device list refreshes automatically.
+- If Bluetooth is off: **Open Bluetooth settings** to turn it on, then return.
+  CarDash checks the adapter state and reuses the cached device list instead of
+  querying the Bluetooth stack on every resume.
+- Connection attempts are bounded. After three failures the card shows
+  **Retry connection** instead of retrying forever.
 - Read-only note: CarDash never writes to the ECU. Adapter settings only; speed
   and temperature choices live in their own cards.
 
@@ -330,10 +335,10 @@ Open-Meteo fallback were verified live on the API 35 emulator. Now-playing
 title, artist, progress and pause/resume were verified against a live Chrome
 MediaSession. The OBD hardware path compiles and is covered by the
 parser/economy design, but no ELM327 adapter is available on this machine;
-socket behavior still needs a car test. Release
-The 1.0.5 universal APK is built from this tree and published; devices on 1.0.4
-will pick it up through Settings → Updates when the release is published. The
-emulator has verified the in-app update path.
+socket behavior still needs a car test. The 1.0.6 universal APK is built from
+this tree and published; devices on 1.0.5 will pick it up through
+Settings → Updates when the release is published. The emulator has verified the
+in-app update path.
 
 ## Troubleshooting the emulator
 
@@ -409,6 +414,8 @@ Android 15 AVD is left running headlessly after development; use
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
   read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
   RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi adapters are not.
+  The adapter list is class/name filtered, and connections stop after three
+  failures so an absent adapter cannot keep the Bluetooth stack busy.
 - **Temperature source** is selectable only while an OBD adapter is selected.
   Weather fallback uses Open-Meteo and the device location; it requires location
   permission and network access, and hides the row when unavailable.

+ 148 - 28
android/app/src/main/kotlin/com/wasp/cardash_launcher/ObdService.kt

@@ -6,6 +6,11 @@ import android.bluetooth.BluetoothSocket
 import java.io.InputStream
 import java.io.OutputStream
 import java.util.UUID
+import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
 import kotlin.math.min
 
 /**
@@ -14,6 +19,10 @@ import kotlin.math.min
  * It deliberately only polls the values CarDash displays: speed, throttle, MAF
  * and ambient temperature. All writes happen through one thread; the Flutter
  * side polls [snapshot] for the latest values.
+ *
+ * Connection attempts are bounded and have a watchdog. A cheap adapter that is
+ * switched off or out of range must not keep the Bluetooth stack in a permanent
+ * connect loop.
  */
 class ObdService(
     private val adapter: BluetoothAdapter,
@@ -23,13 +32,22 @@ class ObdService(
         private val SPP_UUID: UUID =
             UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
         private const val READ_TIMEOUT_MS = 2_000L
+        private const val CONNECT_TIMEOUT_MS = 12_000L
+        private const val MAX_FAILURES = 3
         private const val AMBIENT_EVERY = 30
     }
 
-    private var thread: Thread? = null
+    private val socketLock = Any()
+    private val attemptId = AtomicInteger(0)
+    private val watchdog = Executors.newSingleThreadScheduledExecutor { runnable ->
+        Thread(runnable, "cardash-obd-watchdog").also { it.isDaemon = true }
+    }
+
+    @Volatile private var thread: Thread? = null
     @Volatile private var running = true
     @Volatile private var connected = false
     @Volatile private var connecting = true
+    @Volatile private var retryExhausted = false
     @Volatile private var speedKmh: Double? = null
     @Volatile private var throttlePercent: Double? = null
     @Volatile private var mafGramsPerSecond: Double? = null
@@ -37,11 +55,17 @@ class ObdService(
     @Volatile private var error: String? = null
     @Volatile private var lastPollAt: Long = 0L
 
-    private var socket: BluetoothSocket? = null
-    private var input: InputStream? = null
-    private var output: OutputStream? = null
+    @Volatile private var socket: BluetoothSocket? = null
+    @Volatile private var input: InputStream? = null
+    @Volatile private var output: OutputStream? = null
+    private var connectTimeout: ScheduledFuture<*>? = null
+    private var failures = 0
 
+    @Synchronized
     fun start() {
+        if (thread?.isAlive == true) return
+        running = true
+        retryExhausted = false
         thread = Thread({ runLoop() }, "cardash-obd").also {
             it.isDaemon = true
             it.start()
@@ -52,6 +76,10 @@ class ObdService(
         running = false
         connected = false
         connecting = false
+        retryExhausted = false
+        attemptId.incrementAndGet()
+        cancelConnectTimeout()
+        watchdog.shutdownNow()
         closeSocket()
         thread?.interrupt()
         thread = null
@@ -67,54 +95,138 @@ class ObdService(
             "ambient" to ambientCelsius,
             "error" to error,
             "updatedAt" to lastPollAt,
+            "retryExhausted" to retryExhausted,
         )
     }
 
     private fun runLoop() {
         var backoff = 1_000L
         while (running) {
+            connecting = true
+            connected = false
+            error = null
+            val attempt = attemptId.incrementAndGet()
             try {
-                connecting = true
-                connected = false
-                error = null
-                socket = device.createRfcommSocketToServiceRecord(SPP_UUID)
+                val candidate = device.createRfcommSocketToServiceRecord(SPP_UUID)
+                var stopped = false
+                synchronized(socketLock) {
+                    if (!running) {
+                        stopped = true
+                    } else {
+                        socket = candidate
+                    }
+                }
+                if (stopped) {
+                    closeQuietly(candidate)
+                    return
+                }
                 try {
                     adapter.cancelDiscovery()
                 } catch (_: Throwable) {
                     // Discovery is optional; some stacks reject this without scan permission.
                 }
-                socket?.connect()
-                input = socket?.inputStream
-                output = socket?.outputStream
+                scheduleConnectTimeout(candidate, attempt)
+                candidate.connect()
+                // Invalidate the watchdog before it can close a live socket.
+                attemptId.incrementAndGet()
+                cancelConnectTimeout()
+                if (!running) {
+                    closeSocket()
+                    return
+                }
+
+                val candidateInput = candidate.inputStream
+                val candidateOutput = candidate.outputStream
+                stopped = false
+                synchronized(socketLock) {
+                    if (!running) {
+                        stopped = true
+                    } else {
+                        input = candidateInput
+                        output = candidateOutput
+                    }
+                }
+                if (stopped) {
+                    closeQuietly(candidate)
+                    return
+                }
+
                 handshake()
+                if (!running) {
+                    closeSocket()
+                    return
+                }
                 connected = true
                 connecting = false
                 error = null
+                failures = 0
+                retryExhausted = false
                 backoff = 1_000L
                 pollLoop()
             } catch (problem: Throwable) {
                 connected = false
                 connecting = false
-                error = problem.message ?: "OBD connection lost"
+                cancelConnectTimeout()
                 closeSocket()
-                if (running) {
-                    try {
-                        Thread.sleep(backoff)
-                    } catch (_: InterruptedException) {
-                        Thread.currentThread().interrupt()
-                    }
-                    backoff = min(backoff * 2, 10_000L)
+                if (!running) break
+                failures++
+                if (failures >= MAX_FAILURES) {
+                    retryExhausted = true
+                    error = "Could not connect to the OBD-II adapter. Tap Retry."
+                    running = false
+                    break
                 }
+                error = "OBD adapter did not respond (attempt $failures/$MAX_FAILURES)."
+                try {
+                    Thread.sleep(backoff)
+                } catch (_: InterruptedException) {
+                    Thread.currentThread().interrupt()
+                    break
+                }
+                backoff = min(backoff * 2, 10_000L)
+            } finally {
+                cancelConnectTimeout()
             }
         }
         connected = false
         connecting = false
+        if (retryExhausted) {
+            error = "Could not connect to the OBD-II adapter. Tap Retry."
+        }
+    }
+
+    private fun scheduleConnectTimeout(target: BluetoothSocket, attempt: Int) {
+        cancelConnectTimeout()
+        connectTimeout = try {
+            watchdog.schedule(
+                {
+                    if (attemptId.get() == attempt) {
+                        // Closing the socket aborts a blocked RFCOMM connect.
+                        closeSocket()
+                    }
+                },
+                CONNECT_TIMEOUT_MS,
+                TimeUnit.MILLISECONDS,
+            )
+        } catch (_: RejectedExecutionException) {
+            null
+        }
+    }
+
+    private fun cancelConnectTimeout() {
+        connectTimeout?.cancel(false)
+        connectTimeout = null
     }
 
     private fun handshake() {
+        var responded = false
         for (command in listOf("ATZ", "ATE0", "ATL0", "ATS0", "ATSP0")) {
             send(command)
-            readResponse(if (command == "ATZ") 3_000L else 2_000L)
+            val response = readResponse(if (command == "ATZ") 3_000L else 2_000L)
+            if (response.isNotBlank()) responded = true
+        }
+        if (!responded) {
+            throw IllegalStateException("OBD adapter did not respond")
         }
     }
 
@@ -175,24 +287,24 @@ class ObdService(
     }
 
     private fun send(command: String) {
-        val out = output ?: error("OBD socket is not open")
+        val out = output ?: throw IllegalStateException("OBD socket is not open")
         out.write("$command\r".toByteArray())
         out.flush()
     }
 
     private fun readResponse(timeoutMs: Long): String {
-        val input = input ?: return ""
+        val source = input ?: return ""
         val deadline = System.currentTimeMillis() + timeoutMs
         val bytes = StringBuilder()
         val buffer = ByteArray(512)
         while (running && System.currentTimeMillis() < deadline) {
             val available = try {
-                input.available()
+                source.available()
             } catch (_: Throwable) {
                 0
             }
             if (available > 0) {
-                val count = input.read(buffer)
+                val count = source.read(buffer)
                 if (count > 0) {
                     bytes.append(String(buffer, 0, count, Charsets.US_ASCII))
                     if (bytes.contains('>')) break
@@ -210,13 +322,21 @@ class ObdService(
     }
 
     private fun closeSocket() {
+        val doomed = synchronized(socketLock) {
+            val current = socket
+            socket = null
+            input = null
+            output = null
+            current
+        }
+        closeQuietly(doomed)
+    }
+
+    private fun closeQuietly(target: BluetoothSocket?) {
         try {
-            socket?.close()
+            target?.close()
         } catch (_: Throwable) {
             // Best effort.
         }
-        socket = null
-        input = null
-        output = null
     }
 }

+ 294 - 105
android/app/src/main/kotlin/com/wasp/cardash_launcher/TelemetryBridge.kt

@@ -3,6 +3,7 @@ package com.wasp.cardash_launcher
 import android.Manifest
 import android.app.Activity
 import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
 import android.bluetooth.BluetoothManager
 import android.content.Intent
 import android.content.pm.PackageManager
@@ -20,12 +21,16 @@ import io.flutter.plugin.common.MethodCall
 import io.flutter.plugin.common.MethodChannel
 import java.util.Locale
 import java.util.concurrent.Executors
+import java.util.concurrent.RejectedExecutionException
 
 /**
  * Bluetooth OBD, bonded-device listing and one-shot location for weather.
  *
  * OBD is Bluetooth Classic SPP only. The adapter itself is never scanned here;
  * the user pairs the ELM327 in Android Settings first.
+ *
+ * Bluetooth binder calls are deliberately kept off the Android main thread.
+ * A slow Bluetooth service must never be able to block input or rendering.
  */
 class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
 
@@ -37,9 +42,17 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         private const val KIND_LOCATION = 2
     }
 
+    private class TelemetryFailure(val code: String, message: String) : Exception(message)
+
     private var channel: MethodChannel? = null
+
+    @Volatile
     private var obdService: ObdService? = null
+
     private val mainHandler = Handler(Looper.getMainLooper())
+    private val bluetoothExecutor = Executors.newSingleThreadExecutor { runnable ->
+        Thread(runnable, "cardash-bluetooth").also { it.isDaemon = true }
+    }
     private val locationExecutor = Executors.newSingleThreadExecutor()
     private var gpsManager: LocationManager? = null
     private var gpsListener: LocationListener? = null
@@ -50,6 +63,7 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
     @Volatile private var gpsUpdatedAt: Long = 0L
     private var pendingPermissionResult: MethodChannel.Result? = null
     private var pendingPermissionKind = 0
+    private var pendingPermissionAt = 0L
     @Volatile private var disposed = false
 
     fun attach(messenger: BinaryMessenger) {
@@ -58,9 +72,28 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
 
     fun dispose() {
         disposed = true
-        obdService?.stop()
+        val service = obdService
         obdService = null
+        if (service != null) {
+            try {
+                bluetoothExecutor.execute { service.stop() }
+            } catch (_: RejectedExecutionException) {
+                // The process is going away; do not block the main thread here.
+            }
+        }
+        // Allow the queued stop to run, but do not accept new Bluetooth work.
+        bluetoothExecutor.shutdown()
         stopGpsSpeed()
+        pendingPermissionResult?.let {
+            try {
+                it.error("disposed", "The activity was closed.", null)
+            } catch (_: Throwable) {
+                // The Flutter engine may already be detached.
+            }
+        }
+        pendingPermissionResult = null
+        pendingPermissionKind = 0
+        pendingPermissionAt = 0L
         channel?.setMethodCallHandler(null)
         channel = null
         locationExecutor.shutdownNow()
@@ -69,17 +102,13 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
     override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
         when (call.method) {
             "listBondedDevices" -> listBondedDevices(result)
-            "isBluetoothEnabled" -> result.success(bluetoothAdapter()?.isEnabled == true)
+            "bluetoothState" -> bluetoothState(result)
             "openBluetoothSettings" -> result.success(openBluetoothSettings())
             "ensureBluetoothPermission" -> ensurePermission(KIND_BLUETOOTH, result)
             "ensureLocationPermission" -> ensurePermission(KIND_LOCATION, result)
             "getLocation" -> getLocation(result)
             "connectObd" -> connectObd(call, result)
-            "disconnectObd" -> {
-                obdService?.stop()
-                obdService = null
-                result.success(true)
-            }
+            "disconnectObd" -> disconnectObd(result)
             "getObdState" -> result.success(obdService?.snapshot() ?: emptyObdState())
             "startGpsSpeed" -> result.success(startGpsSpeed())
             "stopGpsSpeed" -> {
@@ -97,6 +126,7 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         if (requestCode != REQUEST_BLUETOOTH && requestCode != REQUEST_LOCATION) return
         pendingPermissionResult = null
         pendingPermissionKind = 0
+        pendingPermissionAt = 0L
         result.success(
             if (kind == KIND_BLUETOOTH) hasBluetoothPermission() else hasLocationPermission(),
         )
@@ -112,9 +142,55 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
             "ambient" to null,
             "error" to null,
             "updatedAt" to 0L,
+            "retryExhausted" to false,
         )
     }
 
+    /**
+     * Runs a Bluetooth operation on one serialized worker thread and posts the
+     * result back to the platform thread. The worker is intentionally not
+     * recreated when a binder call hangs: serializing is safer for the stack,
+     * and the UI stays responsive while the Dart side times out.
+     */
+    private fun runBluetooth(
+        result: MethodChannel.Result,
+        block: () -> Any?,
+        onError: (Throwable) -> Unit,
+    ) {
+        if (disposed) {
+            result.error("disposed", "The Bluetooth bridge is closed.", null)
+            return
+        }
+        try {
+            bluetoothExecutor.execute {
+                try {
+                    val value = block()
+                    mainHandler.post {
+                        if (!disposed) {
+                            try {
+                                result.success(value)
+                            } catch (_: Throwable) {
+                                // The Dart side may have timed out.
+                            }
+                        }
+                    }
+                } catch (problem: Throwable) {
+                    mainHandler.post {
+                        if (!disposed) {
+                            try {
+                                onError(problem)
+                            } catch (_: Throwable) {
+                                // The Dart side may have timed out.
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (_: RejectedExecutionException) {
+            result.error("unavailable", "Bluetooth is shutting down.", null)
+        }
+    }
+
     private fun bluetoothAdapter(): BluetoothAdapter? {
         return try {
             activity.getSystemService(BluetoothManager::class.java)?.adapter
@@ -148,16 +224,222 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         return fine || coarse
     }
 
-
     private fun hasFineLocationPermission(): Boolean {
         return activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
             PackageManager.PERMISSION_GRANTED
     }
 
-    /**
-     * Starts GPS speed updates without requesting permission. Returns false when
-     * precise location is not already granted or the GPS provider is disabled.
-     */
+    private fun bluetoothState(result: MethodChannel.Result) {
+        runBluetooth(
+            result,
+            block = {
+                val adapter = bluetoothAdapter()
+                when {
+                    adapter == null -> "unavailable"
+                    adapter.state == BluetoothAdapter.STATE_ON -> "on"
+                    adapter.state == BluetoothAdapter.STATE_TURNING_ON -> "turning_on"
+                    adapter.state == BluetoothAdapter.STATE_TURNING_OFF -> "turning_off"
+                    else -> "off"
+                }
+            },
+            onError = { result.success("unavailable") },
+        )
+    }
+
+    private fun listBondedDevices(result: MethodChannel.Result) {
+        runBluetooth(
+            result,
+            block = {
+                if (!hasBluetoothPermission()) {
+                    throw TelemetryFailure(
+                        "permission",
+                        "Bluetooth permission is required.",
+                    )
+                }
+                val adapter = bluetoothAdapter()
+                    ?: throw TelemetryFailure(
+                        "unavailable",
+                        "Bluetooth is not available on this device.",
+                    )
+                when (adapter.state) {
+                    BluetoothAdapter.STATE_TURNING_ON -> throw TelemetryFailure(
+                        "turning_on",
+                        "Bluetooth is still starting.",
+                    )
+                    BluetoothAdapter.STATE_TURNING_OFF -> throw TelemetryFailure(
+                        "turning_off",
+                        "Bluetooth is turning off.",
+                    )
+                    BluetoothAdapter.STATE_ON -> Unit
+                    else -> throw TelemetryFailure("disabled", "Bluetooth is turned off.")
+                }
+                val bonded = try {
+                    adapter.bondedDevices
+                } catch (problem: Throwable) {
+                    throw TelemetryFailure(
+                        "list_failed",
+                        problem.message ?: "Could not list Bluetooth devices.",
+                    )
+                } ?: emptySet()
+
+                bonded.map { toDeviceMap(it) }
+                    .sortedBy {
+                        (it["name"] as? String)?.lowercase(Locale.getDefault()) ?: ""
+                    }
+            },
+            onError = { problem ->
+                val failure = problem as? TelemetryFailure
+                result.error(
+                    failure?.code ?: "list_failed",
+                    failure?.message
+                        ?: problem.message
+                        ?: "Could not list Bluetooth devices.",
+                    null,
+                )
+            },
+        )
+    }
+
+    private fun toDeviceMap(device: BluetoothDevice): Map<String, Any?> {
+        val name = try {
+            device.name
+        } catch (_: Throwable) {
+            null
+        } ?: "Unknown device"
+        val deviceClass = try {
+            device.bluetoothClass?.deviceClass
+        } catch (_: Throwable) {
+            null
+        }
+        val uuids = try {
+            device.uuids?.map { it.uuid.toString().lowercase(Locale.US) }
+        } catch (_: Throwable) {
+            null
+        }
+        return mapOf<String, Any?>(
+            "name" to name,
+            "address" to device.address,
+            "deviceClass" to deviceClass,
+            "uuids" to uuids,
+        )
+    }
+
+    private fun ensurePermission(kind: Int, result: MethodChannel.Result) {
+        val granted = if (kind == KIND_BLUETOOTH) {
+            hasBluetoothPermission()
+        } else {
+            hasLocationPermission()
+        }
+        if (granted) {
+            result.success(true)
+            return
+        }
+        if (pendingPermissionResult != null) {
+            val age = System.currentTimeMillis() - pendingPermissionAt
+            if (age < 30_000L) {
+                result.error(
+                    "permission_busy",
+                    "Another permission request is in progress.",
+                    null,
+                )
+                return
+            }
+            // The Dart side has already timed out. Drop the stale request so a
+            // later Find tap can ask again.
+            pendingPermissionResult = null
+            pendingPermissionKind = 0
+            pendingPermissionAt = 0L
+        }
+        pendingPermissionResult = result
+        pendingPermissionKind = kind
+        pendingPermissionAt = System.currentTimeMillis()
+
+        val permissions = if (kind == KIND_BLUETOOTH) {
+            arrayOf(Manifest.permission.BLUETOOTH_CONNECT)
+        } else {
+            arrayOf(
+                Manifest.permission.ACCESS_FINE_LOCATION,
+                Manifest.permission.ACCESS_COARSE_LOCATION,
+            )
+        }
+        val code = if (kind == KIND_BLUETOOTH) REQUEST_BLUETOOTH else REQUEST_LOCATION
+        try {
+            activity.requestPermissions(permissions, code)
+        } catch (_: Throwable) {
+            pendingPermissionResult = null
+            pendingPermissionKind = 0
+            result.success(false)
+        }
+    }
+
+    private fun connectObd(call: MethodCall, result: MethodChannel.Result) {
+        val mac = call.argument<String>("address")
+        if (mac.isNullOrBlank()) {
+            result.error("bad_arguments", "A Bluetooth address is required.", null)
+            return
+        }
+        runBluetooth(
+            result,
+            block = {
+                if (!hasBluetoothPermission()) {
+                    throw TelemetryFailure(
+                        "permission",
+                        "Bluetooth permission is required.",
+                    )
+                }
+                val adapter = bluetoothAdapter()
+                    ?: throw TelemetryFailure(
+                        "unavailable",
+                        "Bluetooth is not available on this device.",
+                    )
+                if (adapter.state != BluetoothAdapter.STATE_ON) {
+                    throw TelemetryFailure("disabled", "Bluetooth is turned off.")
+                }
+                val device = try {
+                    adapter.getRemoteDevice(mac)
+                } catch (problem: Throwable) {
+                    throw TelemetryFailure(
+                        "bad_address",
+                        problem.message ?: "Unknown Bluetooth address.",
+                    )
+                }
+
+                val previous = obdService
+                obdService = null
+                previous?.stop()
+
+                if (disposed) {
+                    throw TelemetryFailure("disposed", "The Bluetooth bridge is closed.")
+                }
+                val service = ObdService(adapter, device)
+                obdService = service
+                service.start()
+                true
+            },
+            onError = { problem ->
+                val failure = problem as? TelemetryFailure
+                result.error(
+                    failure?.code ?: "connect_failed",
+                    failure?.message ?: problem.message ?: "Could not start OBD.",
+                    null,
+                )
+            },
+        )
+    }
+
+    private fun disconnectObd(result: MethodChannel.Result) {
+        val service = obdService
+        obdService = null
+        if (service != null) {
+            try {
+                bluetoothExecutor.execute { service.stop() }
+            } catch (_: RejectedExecutionException) {
+                // Shutting down; the queued stop is not needed.
+            }
+        }
+        result.success(true)
+    }
+
     private fun startGpsSpeed(): Boolean {
         if (!hasFineLocationPermission()) {
             stopGpsSpeed()
@@ -279,99 +561,6 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         )
     }
 
-    private fun ensurePermission(kind: Int, result: MethodChannel.Result) {
-        val granted = if (kind == KIND_BLUETOOTH) {
-            hasBluetoothPermission()
-        } else {
-            hasLocationPermission()
-        }
-        if (granted) {
-            result.success(true)
-            return
-        }
-        if (pendingPermissionResult != null) {
-            result.error("permission_busy", "Another permission request is in progress.", null)
-            return
-        }
-        pendingPermissionResult = result
-        pendingPermissionKind = kind
-
-        val permissions = if (kind == KIND_BLUETOOTH) {
-            arrayOf(Manifest.permission.BLUETOOTH_CONNECT)
-        } else {
-            arrayOf(
-                Manifest.permission.ACCESS_FINE_LOCATION,
-                Manifest.permission.ACCESS_COARSE_LOCATION,
-            )
-        }
-        val code = if (kind == KIND_BLUETOOTH) REQUEST_BLUETOOTH else REQUEST_LOCATION
-        try {
-            activity.requestPermissions(permissions, code)
-        } catch (_: Throwable) {
-            pendingPermissionResult = null
-            pendingPermissionKind = 0
-            result.success(false)
-        }
-    }
-
-    private fun listBondedDevices(result: MethodChannel.Result) {
-        if (!hasBluetoothPermission()) {
-            result.error("permission", "Bluetooth permission is required.", null)
-            return
-        }
-        val adapter = bluetoothAdapter()
-        if (adapter == null) {
-            result.error("unavailable", "Bluetooth is not available on this device.", null)
-            return
-        }
-        if (!adapter.isEnabled) {
-            result.error("disabled", "Bluetooth is turned off.", null)
-            return
-        }
-        try {
-            val devices = adapter.bondedDevices
-                .map {
-                    mapOf(
-                        "name" to (it.name ?: "Unknown device"),
-                        "address" to it.address,
-                    )
-                }
-                .sortedBy { (it["name"] as? String)?.lowercase(Locale.getDefault()) ?: "" }
-            result.success(devices)
-        } catch (problem: Throwable) {
-            result.error("list_failed", problem.message ?: "Could not list Bluetooth devices.", null)
-        }
-    }
-
-    private fun connectObd(call: MethodCall, result: MethodChannel.Result) {
-        val mac = call.argument<String>("address")
-        if (mac.isNullOrBlank()) {
-            result.error("bad_arguments", "A Bluetooth address is required.", null)
-            return
-        }
-        if (!hasBluetoothPermission()) {
-            result.error("permission", "Bluetooth permission is required.", null)
-            return
-        }
-        val adapter = bluetoothAdapter()
-        if (adapter == null) {
-            result.error("unavailable", "Bluetooth is not available on this device.", null)
-            return
-        }
-        if (!adapter.isEnabled) {
-            result.error("disabled", "Bluetooth is turned off.", null)
-            return
-        }
-        try {
-            obdService?.stop()
-            val device = adapter.getRemoteDevice(mac)
-            obdService = ObdService(adapter, device).also { it.start() }
-            result.success(true)
-        } catch (problem: Throwable) {
-            result.error("connect_failed", problem.message ?: "Could not start OBD.", null)
-        }
-    }
-
     private fun getLocation(result: MethodChannel.Result) {
         if (!hasLocationPermission()) {
             result.error("permission", "Location permission is required.", null)

+ 8 - 1
docs/IMPLEMENTATION_PLAN.md

@@ -158,11 +158,12 @@ Android (Kotlin)
 | M19 | OBD-II/Speed/Temperature Settings cards; source switches; 10-minute first-install hint; 1.0.3 | Done |
 | M20 | Fixed-volume car detection; relative volume fallback; 1.0.4 | Done |
 | M21 | On-screen volume diagnostics and stream snapshots; 1.0.5 | Done |
+| M22 | Bluetooth/OBD freeze hardening: UI-thread offload, device filtering, bounded retries, resume debounce; 1.0.6 | Done |
 
 ## 5. Verification checklist
 
 - [x] `flutter analyze` clean.
-- [x] Unit/widget tests pass (23 tests: PIN, repository, theme/accent, battery, first-run state, slot visibility, pinned apps, custom labels/icons, cap, dense grid, equalizer).
+- [x] Unit/widget tests pass (76 tests, including OBD device filtering, bounded retries and resume debounce).
 - [x] Debug APK builds with JDK 21.
 - [x] Installs on API 35 1280×800 tablet.
 - [x] HOME role granted; Home opens CarDash.
@@ -325,6 +326,12 @@ Android (Kotlin)
 - 2026-10-30: Added fixed-volume detection and a relative volume-step fallback
   for car units that ignore absolute `setStreamVolume`; fixed-volume sliders now
   explain that the vehicle amplifier owns the level. Bumped to 1.0.4+16.
+- 2026-10-31: Hardened the OBD-II/Bluetooth path after a freeze triggered by
+  returning from Bluetooth settings. Bluetooth binder calls now run on a
+  serialized native worker with timeouts; the picker filters to likely OBD-II
+  adapters; connections stop after three failures with a **Retry connection**
+  action; and resume reuses the cached bonded list instead of querying it every
+  time. Bumped to 1.0.6+18.
 - 2026-10-30: Added a long-press volume diagnostics panel with all stream
   indices and the last target/actual write, plus `CarDashVolume` logcat output,
   to identify devices that ignore both absolute and relative volume writes.

+ 32 - 10
docs/OBD_TEMPERATURE.md

@@ -1,7 +1,7 @@
 # CarDash — OBD-II and temperature design note
 
-_Status: implemented in CarDash 1.0.3; OBD hardware still needs a real-car test.
-Last updated: 2026-10-30_
+_Status: updated in CarDash 1.0.6; OBD hardware still needs a real-car test.
+Last updated: 2026-10-31_
 
 ## 1. Scope
 
@@ -46,6 +46,9 @@ coast above 10 km/h, and shows `—` until at least 0.5 km has accumulated.
 | D8 | Speed and temperature each have an independent show/hide switch in Settings. |
 | D9 | Source switches appear only while an OBD-II adapter is selected; GPS/weather is the fallback. |
 | D10 | Temperature starts off on fresh installs; upgrades that already used OBD or weather keep it on. |
+| D11 | The picker defaults to devices that look like Bluetooth Classic OBD-II adapters; **Show all paired devices** is the explicit escape hatch. |
+| D12 | Every Bluetooth binder call runs on one serialized native worker, never on the MethodChannel/main thread. |
+| D13 | Connection attempts stop after three failures; the card offers **Retry connection**. There is no infinite reconnect loop. |
 
 ## 3. Permissions
 
@@ -68,13 +71,14 @@ in `SettingsRepository`.
 ```
 Kotlin
   TelemetryBridge  MethodChannel "com.wasp.cardash_launcher/telemetry"
-    - listBondedDevices / isBluetoothEnabled / ensureBluetoothPermission
+    - bluetoothState / listBondedDevices / ensureBluetoothPermission
     - connectObd(mac) / disconnectObd / getObdState
     - ensureLocationPermission / getLocation (last-known or single fix + Geocoder)
+    - all Bluetooth binder work runs on the cardash-bluetooth worker
   ObdService       one worker thread over BluetoothSocket/RFCOMM SPP
     - handshake ATZ, ATE0, ATL0, ATS0, ATSP0
     - request/response serialized, prompt-terminated
-    - latest snapshot polled by Dart
+    - 12 s connect watchdog, three attempts, then retryExhausted
 
 Dart
   NativeTelemetryBridge  platform boundary
@@ -92,7 +96,9 @@ The native `ObdService` polls speed, throttle and MAF roughly once per second;
 ambient temperature is refreshed every 30 poll cycles. A snapshot carries
 `updatedAt`; the Dart side treats data older than five seconds as stale and
 hides the live card. Connection failures surface as a short Settings status and
-keep the dashboard quiet.
+keep the dashboard quiet. A failure after three attempts sets `retryExhausted`;
+the card then shows **Retry connection** and the controller does not auto-retry
+on the next resume.
 
 ## 5. Home UI
 
@@ -124,10 +130,16 @@ keep the dashboard quiet.
 ## 6. Settings cards
 
 **OBD-II**
-- Adapter dropdown containing `None` and paired Bluetooth Classic devices.
-  `Find`/`Allow` requests Bluetooth permission and refreshes the list.
-- Connection status: Off / Connecting / Connected / waiting for data / error.
-- Bluetooth-off action: **Open Bluetooth settings**.
+- Adapter dropdown containing `None` and paired Bluetooth Classic devices that
+  look like OBD-II adapters (known name, SPP UUID, or a plausible major class).
+  **Show all paired devices** reveals phones, headsets and other bonded devices.
+- `Find`/`Allow` requests Bluetooth permission and refreshes the list.
+- Connection status: Off / starting / turning off / unavailable / Connecting /
+  Connected / waiting for data / error.
+- Bluetooth-off action: **Open Bluetooth settings**. On return, the controller
+  checks the adapter state and reuses the cached list; it does not run a bond
+  query on every resume.
+- After three failed connection attempts: **Retry connection**.
 - Read-only note: CarDash never writes to the ECU.
 - This card contains adapter settings only.
 
@@ -171,11 +183,17 @@ coordinates are stored.
 - Weather failure → no row.
 - Permission denial is a normal state; the Temperature card shows the weather
   error while OBD temperature can still work.
+- Three consecutive connect/handshake failures stop the worker and expose
+  **Retry connection**; this prevents an absent adapter from keeping the
+  Bluetooth stack in a connect loop.
+- Bluetooth power transitions show a status instead of listing devices while
+  the adapter is still turning on or off.
 - The OBD status is visible only in Settings; the dashboard stays quiet.
 
 ## 9. Testing
 
-- Dart unit tests cover the rolling economy math, the OBD card and the
+- Dart unit tests cover the rolling economy math, the OBD card, adapter
+  classification/filtering, bounded-retry state, resume caching and the
   speed/temperature preference controller.
 - Settings persistence covers the adapter address, visibility switches, source
   choices and weather preference.
@@ -197,3 +215,7 @@ coordinates are stored.
   independent visibility toggles and OBD/GPS-weather source switches.
 - 2026-10-30 — Retired the two-minute process-start onboarding hint in favor of
   a ten-minute first-install window that never returns after an upgrade.
+- 2026-10-31 — After a freeze that followed a Bluetooth settings return, moved
+  all Bluetooth binder calls to a serialized worker, filtered the adapter picker,
+  bounded connection attempts to three, added a 12 s connect watchdog and made
+  resume reuse the cached bonded list.

+ 0 - 4
lib/app.dart

@@ -59,10 +59,6 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         unawaited(context.read<ObdController>().start());
         unawaited(context.read<GpsSpeedController>().start());
         unawaited(context.read<UpdateController>().start());
-        unawaited(context.read<UpdateController>().start());
-        unawaited(context.read<GpsSpeedController>().start());
-        unawaited(context.read<UpdateController>().start());
-        unawaited(context.read<UpdateController>().start());
         unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();
       case AppLifecycleState.inactive:

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// Keep this in sync with the `version` in pubspec.yaml.
-const String appVersion = '1.0.5';
+const String appVersion = '1.0.6';

+ 71 - 1
lib/data/models/bluetooth_device_info.dart

@@ -1,16 +1,86 @@
 /// A Bluetooth Classic device already paired with the tablet.
+///
+/// [deviceClass] is Android's class-of-device value. CarDash uses it to keep
+/// phones, headsets and other non-SPP devices out of the default OBD-II
+/// adapter list. Devices with no class information stay visible; hiding a real
+/// adapter would be worse than showing an extra device.
 class BluetoothDeviceInfo {
-  const BluetoothDeviceInfo({required this.name, required this.address});
+  const BluetoothDeviceInfo({
+    required this.name,
+    required this.address,
+    this.deviceClass,
+    this.uuids = const [],
+  });
+
+  static const _sppUuid = '00001101-0000-1000-8000-00805f9b34fb';
+
+  /// Names commonly used by ELM327 and compatible OBD-II adapters.
+  static const _obdNameHints = <String>[
+    'obd',
+    'elm',
+    'vlink',
+    'v-link',
+    'vgate',
+    'v-gate',
+    'veepeak',
+    'bafx',
+    'obdlink',
+    'scantool',
+    'konnwei',
+    'autel',
+    'car scanner',
+  ];
+
+  /// Android major device classes that can plausibly carry an SPP ELM327.
+  static const _likelyMajorClasses = <int>{
+    0x0000, // Miscellaneous
+    0x0100, // Computer
+    0x0300, // Networking / LAN access
+    0x1F00, // Uncategorized
+  };
 
   final String name;
   final String address;
+  final int? deviceClass;
+
+  /// Cached SDP UUIDs, when Android already has them.
+  final List<String> uuids;
 
   factory BluetoothDeviceInfo.fromMap(Map<dynamic, dynamic> map) {
+    final rawUuids = map['uuids'];
     return BluetoothDeviceInfo(
       name: (map['name'] as String?)?.trim() ?? 'Unknown device',
       address: (map['address'] as String?) ?? '',
+      deviceClass: (map['deviceClass'] as num?)?.toInt(),
+      uuids: rawUuids is List
+          ? rawUuids
+                .whereType<String>()
+                .map((uuid) => uuid.toLowerCase())
+                .toList(growable: false)
+          : const [],
     );
   }
 
+  int? get majorClass => deviceClass == null ? null : deviceClass! & 0x1F00;
+
+  bool get hasSppService => uuids.contains(_sppUuid);
+
+  bool get hasObdName {
+    final normalized = name.toLowerCase();
+    return _obdNameHints.any(normalized.contains);
+  }
+
+  /// Whether the adapter is likely to be a Bluetooth Classic OBD-II device.
+  ///
+  /// A recognised name or the SPP service UUID always wins. Otherwise the
+  /// Android major class decides; unknown class information is treated as
+  /// possibly usable so a real adapter is never hidden.
+  bool get isLikelyObd {
+    if (hasObdName || hasSppService) return true;
+    final major = majorClass;
+    if (major == null) return true;
+    return _likelyMajorClasses.contains(major);
+  }
+
   String get label => name.isEmpty ? address : '$name · $address';
 }

+ 32 - 0
lib/data/models/bluetooth_power_state.dart

@@ -0,0 +1,32 @@
+/// Bluetooth adapter power state reported by the native bridge.
+///
+/// `turningOn` and `turningOff` are deliberately separate from `on`/`off` so
+/// the Settings card never lists bonded devices while the stack is still
+/// initializing.
+enum BluetoothPowerState {
+  unknown,
+  unavailable,
+  off,
+  turningOn,
+  on,
+  turningOff;
+
+  static BluetoothPowerState fromWire(Object? value) {
+    return switch (value) {
+      'on' => on,
+      'off' => off,
+      'turning_on' => turningOn,
+      'turning_off' => turningOff,
+      'unavailable' => unavailable,
+      _ => unknown,
+    };
+  }
+
+  bool get isOn => this == BluetoothPowerState.on;
+
+  bool get isOff => this == BluetoothPowerState.off;
+
+  bool get isTransitioning =>
+      this == BluetoothPowerState.turningOn ||
+      this == BluetoothPowerState.turningOff;
+}

+ 5 - 0
lib/data/models/obd_state.dart

@@ -9,6 +9,7 @@ class ObdState {
     this.ambientCelsius,
     this.error,
     this.updatedAt = 0,
+    this.retryExhausted = false,
   });
 
   final bool connected;
@@ -20,6 +21,9 @@ class ObdState {
   final String? error;
   final int updatedAt;
 
+  /// The native worker stopped retrying after repeated connection failures.
+  final bool retryExhausted;
+
   bool get hasVehicleData =>
       connected && (speedKmh != null || throttlePercent != null);
 
@@ -36,6 +40,7 @@ class ObdState {
       ambientCelsius: number('ambient'),
       error: map['error'] as String?,
       updatedAt: (map['updatedAt'] as num?)?.toInt() ?? 0,
+      retryExhausted: map['retryExhausted'] == true,
     );
   }
 }

+ 93 - 19
lib/data/services/telemetry_bridge.dart

@@ -1,15 +1,21 @@
+import 'dart:async';
+
 import 'package:flutter/services.dart';
 
 import '../models/bluetooth_device_info.dart';
+import '../models/bluetooth_power_state.dart';
 import '../models/gps_speed.dart';
 import '../models/location_reading.dart';
 import '../models/obd_state.dart';
 
 class TelemetryException implements Exception {
-  TelemetryException(this.message);
+  TelemetryException(this.message, {this.code});
 
   final String message;
 
+  /// Stable native error code, when one was supplied.
+  final String? code;
+
   @override
   String toString() => message;
 }
@@ -19,7 +25,7 @@ class TelemetryException implements Exception {
 abstract class TelemetryPlatform {
   Future<List<BluetoothDeviceInfo>> listBondedDevices();
 
-  Future<bool> isBluetoothEnabled();
+  Future<BluetoothPowerState> bluetoothState();
 
   Future<bool> openBluetoothSettings();
 
@@ -46,19 +52,32 @@ abstract class TelemetryPlatform {
 class NativeTelemetryBridge implements TelemetryPlatform {
   static const _channel = MethodChannel('com.wasp.cardash_launcher/telemetry');
 
+  static const _listTimeout = Duration(seconds: 6);
+  static const _stateTimeout = Duration(seconds: 3);
+  static const _permissionTimeout = Duration(seconds: 30);
+  static const _connectTimeout = Duration(seconds: 5);
+  static const _disconnectTimeout = Duration(seconds: 3);
+  static const _obdStateTimeout = Duration(seconds: 2);
+  static const _settingsTimeout = Duration(seconds: 3);
+
   @override
   Future<List<BluetoothDeviceInfo>> listBondedDevices() async {
     try {
-      final raw = await _channel.invokeMethod<dynamic>('listBondedDevices');
+      final raw = await _channel
+          .invokeMethod<dynamic>('listBondedDevices')
+          .timeout(_listTimeout);
       if (raw is! List) return const [];
       return raw
           .whereType<Map>()
           .map(BluetoothDeviceInfo.fromMap)
           .where((device) => device.address.isNotEmpty)
           .toList(growable: false);
+    } on TimeoutException {
+      throw TelemetryException('Bluetooth is not responding.', code: 'timeout');
     } on PlatformException catch (error) {
       throw TelemetryException(
         error.message ?? 'Could not list Bluetooth devices.',
+        code: error.code,
       );
     } on MissingPluginException {
       throw TelemetryException('The telemetry bridge is unavailable.');
@@ -66,21 +85,30 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   }
 
   @override
-  Future<bool> isBluetoothEnabled() async {
+  Future<BluetoothPowerState> bluetoothState() async {
     try {
-      return await _channel.invokeMethod<bool>('isBluetoothEnabled') ?? false;
+      final raw = await _channel
+          .invokeMethod<String>('bluetoothState')
+          .timeout(_stateTimeout);
+      return BluetoothPowerState.fromWire(raw);
+    } on TimeoutException {
+      return BluetoothPowerState.unknown;
     } on PlatformException {
-      return false;
+      return BluetoothPowerState.unavailable;
     } on MissingPluginException {
-      return false;
+      return BluetoothPowerState.unavailable;
     }
   }
 
   @override
   Future<bool> openBluetoothSettings() async {
     try {
-      return await _channel.invokeMethod<bool>('openBluetoothSettings') ??
+      return await _channel
+              .invokeMethod<bool>('openBluetoothSettings')
+              .timeout(_settingsTimeout) ??
           false;
+    } on TimeoutException {
+      return false;
     } on PlatformException {
       return false;
     } on MissingPluginException {
@@ -91,8 +119,15 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<bool> ensureBluetoothPermission() async {
     try {
-      return await _channel.invokeMethod<bool>('ensureBluetoothPermission') ??
+      return await _channel
+              .invokeMethod<bool>('ensureBluetoothPermission')
+              .timeout(_permissionTimeout) ??
           false;
+    } on TimeoutException {
+      throw TelemetryException(
+        'Bluetooth permission request timed out.',
+        code: 'timeout',
+      );
     } on PlatformException {
       return false;
     } on MissingPluginException {
@@ -103,8 +138,12 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<bool> ensureLocationPermission() async {
     try {
-      return await _channel.invokeMethod<bool>('ensureLocationPermission') ??
+      return await _channel
+              .invokeMethod<bool>('ensureLocationPermission')
+              .timeout(_permissionTimeout) ??
           false;
+    } on TimeoutException {
+      return false;
     } on PlatformException {
       return false;
     } on MissingPluginException {
@@ -115,13 +154,20 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<LocationReading> getLocation() async {
     try {
-      final raw = await _channel.invokeMethod<dynamic>('getLocation');
+      final raw = await _channel
+          .invokeMethod<dynamic>('getLocation')
+          .timeout(const Duration(seconds: 20));
       if (raw is! Map) {
         throw TelemetryException('Could not read the current location.');
       }
       return LocationReading.fromMap(raw);
+    } on TimeoutException {
+      throw TelemetryException('Could not read the current location.');
     } on PlatformException catch (error) {
-      throw TelemetryException(error.message ?? 'Could not read location.');
+      throw TelemetryException(
+        error.message ?? 'Could not read location.',
+        code: error.code,
+      );
     } on MissingPluginException {
       throw TelemetryException('The telemetry bridge is unavailable.');
     }
@@ -130,9 +176,16 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<void> connectObd(String address) async {
     try {
-      await _channel.invokeMethod<dynamic>('connectObd', {'address': address});
+      await _channel
+          .invokeMethod<dynamic>('connectObd', {'address': address})
+          .timeout(_connectTimeout);
+    } on TimeoutException {
+      throw TelemetryException('Bluetooth is not responding.', code: 'timeout');
     } on PlatformException catch (error) {
-      throw TelemetryException(error.message ?? 'Could not connect to OBD.');
+      throw TelemetryException(
+        error.message ?? 'Could not connect to OBD.',
+        code: error.code,
+      );
     } on MissingPluginException {
       throw TelemetryException('The telemetry bridge is unavailable.');
     }
@@ -141,7 +194,11 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<void> disconnectObd() async {
     try {
-      await _channel.invokeMethod<dynamic>('disconnectObd');
+      await _channel
+          .invokeMethod<dynamic>('disconnectObd')
+          .timeout(_disconnectTimeout);
+    } on TimeoutException {
+      // The native stop is best effort; never hold up the UI for it.
     } on PlatformException {
       // Nothing useful to do when already disconnected.
     } on MissingPluginException {
@@ -152,7 +209,12 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<bool> startGpsSpeed() async {
     try {
-      return await _channel.invokeMethod<bool>('startGpsSpeed') ?? false;
+      return await _channel
+              .invokeMethod<bool>('startGpsSpeed')
+              .timeout(const Duration(seconds: 3)) ??
+          false;
+    } on TimeoutException {
+      return false;
     } on PlatformException {
       return false;
     } on MissingPluginException {
@@ -163,7 +225,11 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<void> stopGpsSpeed() async {
     try {
-      await _channel.invokeMethod<dynamic>('stopGpsSpeed');
+      await _channel
+          .invokeMethod<dynamic>('stopGpsSpeed')
+          .timeout(const Duration(seconds: 2));
+    } on TimeoutException {
+      // Best effort.
     } on PlatformException {
       // Nothing useful to do when already stopped.
     } on MissingPluginException {
@@ -174,8 +240,12 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<GpsSpeed> getGpsSpeed() async {
     try {
-      final raw = await _channel.invokeMethod<dynamic>('getGpsSpeed');
+      final raw = await _channel
+          .invokeMethod<dynamic>('getGpsSpeed')
+          .timeout(const Duration(seconds: 2));
       return raw is Map ? GpsSpeed.fromMap(raw) : const GpsSpeed();
+    } on TimeoutException {
+      return const GpsSpeed();
     } on PlatformException {
       return const GpsSpeed();
     } on MissingPluginException {
@@ -186,8 +256,12 @@ class NativeTelemetryBridge implements TelemetryPlatform {
   @override
   Future<ObdState> getObdState() async {
     try {
-      final raw = await _channel.invokeMethod<dynamic>('getObdState');
+      final raw = await _channel
+          .invokeMethod<dynamic>('getObdState')
+          .timeout(_obdStateTimeout);
       return raw is Map ? ObdState.fromMap(raw) : const ObdState();
+    } on TimeoutException {
+      throw TelemetryException('OBD state is not responding.', code: 'timeout');
     } on PlatformException {
       return const ObdState();
     } on MissingPluginException {

+ 290 - 23
lib/features/home/obd_controller.dart

@@ -3,6 +3,7 @@ import 'dart:async';
 import 'package:flutter/foundation.dart';
 
 import '../../data/models/bluetooth_device_info.dart';
+import '../../data/models/bluetooth_power_state.dart';
 import '../../data/models/obd_state.dart';
 import '../../data/repositories/settings_repository.dart';
 import '../../data/services/telemetry_bridge.dart';
@@ -23,16 +24,22 @@ class ObdController extends ChangeNotifier {
 
   String? _selectedAddress;
   List<BluetoothDeviceInfo> _devices = const [];
+  bool _devicesKnown = false;
   bool _loadingDevices = false;
   String? _devicesError;
   bool _needsBluetoothPermission = false;
-  bool? _bluetoothEnabled;
+  BluetoothPowerState _bluetoothState = BluetoothPowerState.unknown;
+  DateTime? _lastDeviceRefreshAt;
+  Future<bool>? _deviceRefresh;
+  Timer? _bluetoothRetryTimer;
+  int _bluetoothRetryAttempts = 0;
 
   ObdState _state = const ObdState();
   String? _localError;
   Timer? _pollTimer;
   bool _active = false;
   bool _polling = false;
+  bool _connectionFailed = false;
   bool _disposed = false;
   int? _lastEconomyTimestamp;
 
@@ -40,13 +47,27 @@ class ObdController extends ChangeNotifier {
 
   List<BluetoothDeviceInfo> get devices => _devices;
 
+  /// Paired devices that look like Bluetooth Classic OBD-II adapters.
+  List<BluetoothDeviceInfo> get obdDevices =>
+      List.unmodifiable(_devices.where((device) => device.isLikelyObd));
+
+  /// Paired devices that do not advertise an OBD-II class or name.
+  List<BluetoothDeviceInfo> get otherDevices =>
+      List.unmodifiable(_devices.where((device) => !device.isLikelyObd));
+
   bool get loadingDevices => _loadingDevices;
 
+  bool get devicesKnown => _devicesKnown;
+
   String? get devicesError => _devicesError;
 
   bool get needsBluetoothPermission => _needsBluetoothPermission;
 
-  bool? get bluetoothEnabled => _bluetoothEnabled;
+  BluetoothPowerState get bluetoothState => _bluetoothState;
+
+  bool get bluetoothOff => _bluetoothState.isOff;
+
+  bool get bluetoothTransitioning => _bluetoothState.isTransitioning;
 
   bool get connected => _state.connected;
 
@@ -54,6 +75,12 @@ class ObdController extends ChangeNotifier {
 
   bool get hasConfiguredAdapter => _selectedAddress != null;
 
+  /// The native worker stopped retrying, or the saved device is not a likely
+  /// OBD adapter. Offer an explicit retry action in either case.
+  bool get canRetry =>
+      _selectedAddress != null &&
+      (_connectionFailed || _selectedDeviceIsUnsupported);
+
   String? get error {
     if (_localError != null) return _localError;
     if (_state.connected) return null;
@@ -63,11 +90,24 @@ class ObdController extends ChangeNotifier {
   String get statusLabel {
     if (_loadingDevices) return 'Checking paired devices…';
     if (_needsBluetoothPermission) return 'Bluetooth permission needed';
-    if (_bluetoothEnabled == false) return 'Bluetooth is off';
+    if (_bluetoothState == BluetoothPowerState.turningOn) {
+      return 'Bluetooth is starting…';
+    }
+    if (_bluetoothState == BluetoothPowerState.turningOff) {
+      return 'Bluetooth is turning off…';
+    }
+    if (_bluetoothState == BluetoothPowerState.off) return 'Bluetooth is off';
+    if (_bluetoothState == BluetoothPowerState.unavailable) {
+      return 'Bluetooth is unavailable';
+    }
+    if (_bluetoothState == BluetoothPowerState.unknown) {
+      return 'Bluetooth is not responding';
+    }
     if (connecting) return 'Connecting…';
     if (connected) {
       return isFresh ? 'Connected' : 'Connected — waiting for data';
     }
+    if (_connectionFailed) return 'Not connected — adapter not responding';
     if (error != null) return 'Not connected — $error';
     if (_selectedAddress == null) return 'Off';
     return 'Not connected';
@@ -98,10 +138,47 @@ class ObdController extends ChangeNotifier {
   Future<void> start() async {
     if (_active) return;
     _active = true;
-    if (_selectedAddress != null) {
-      await _connect();
+
+    var attempted = false;
+    if (_selectedAddress != null && !_connectionFailed) {
+      if (!_devicesKnown) {
+        await refreshDevices(force: true);
+      }
+      if (!_active) {
+        _notify();
+        return;
+      }
+
+      final address = _selectedAddress;
+      final selected = _deviceFor(address);
+      if (address == null) {
+        // The user selected None while the device list was loading.
+      } else if (selected == null) {
+        if (_devicesKnown && _bluetoothState.isOn) {
+          _localError = 'Saved adapter is no longer paired.';
+        } else if (!_devicesKnown) {
+          _localError =
+              _devicesError ?? 'Could not read paired Bluetooth devices.';
+        }
+      } else if (!selected.isLikelyObd) {
+        _localError =
+            'Saved adapter does not look like an OBD-II adapter. '
+            'Use Retry connection to try it anyway.';
+      } else {
+        attempted = await _connect();
+      }
+    }
+
+    if (!_active) {
+      _notify();
+      return;
+    }
+    if (attempted) {
+      _startPolling();
+    } else {
+      _pollTimer?.cancel();
+      _pollTimer = null;
     }
-    _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll());
     _notify();
   }
 
@@ -109,6 +186,9 @@ class ObdController extends ChangeNotifier {
     _active = false;
     _pollTimer?.cancel();
     _pollTimer = null;
+    _bluetoothRetryTimer?.cancel();
+    _bluetoothRetryTimer = null;
+    _bluetoothRetryAttempts = 0;
     _lastEconomyTimestamp = null;
     _economy.reset();
     _state = const ObdState();
@@ -120,24 +200,85 @@ class ObdController extends ChangeNotifier {
     if (_selectedAddress == address) return;
     _selectedAddress = address;
     _localError = null;
+    _connectionFailed = false;
     _lastEconomyTimestamp = null;
     _economy.reset();
     _state = const ObdState();
     await _settings.setObdAdapterAddress(address);
     await _platform.disconnectObd();
-    if (_active && address != null) {
-      await _connect();
+    if (_active) {
+      if (address == null) {
+        _pollTimer?.cancel();
+        _pollTimer = null;
+      } else {
+        final started = await _connect();
+        if (started && _active) _startPolling();
+      }
     }
     _notify();
   }
 
+  /// Explicitly retries the saved adapter after the worker gave up.
+  ///
+  /// This bypasses the "looks like an OBD-II adapter" guard so an unusual
+  /// adapter can still be used after the user deliberately selects Retry.
+  Future<void> retryConnection() async {
+    if (_selectedAddress == null) return;
+    _connectionFailed = false;
+    _localError = null;
+    _lastEconomyTimestamp = null;
+    _economy.reset();
+    _state = const ObdState();
+    _notify();
+    await _platform.disconnectObd();
+    if (!_active) return;
+    final started = await _connect();
+    if (started && _active) _startPolling();
+    _notify();
+  }
+
   /// Lists bonded devices. Pass [requestPermission] only when the user asked to
   /// find adapters; the Settings card can show a dedicated allow button.
-  Future<bool> refreshDevices({bool requestPermission = false}) async {
-    if (_loadingDevices) return false;
+  ///
+  /// [dueToResume] performs a cheap adapter-state check but skips the bonded
+  /// list when it is already cached. This keeps the app from querying the
+  /// Bluetooth stack on every lifecycle resume.
+  Future<bool> refreshDevices({
+    bool requestPermission = false,
+    bool force = false,
+    bool dueToResume = false,
+  }) {
+    final inFlight = _deviceRefresh;
+    if (inFlight != null) return inFlight;
+
+    final future = _refreshDevices(
+      requestPermission: requestPermission,
+      force: force,
+      dueToResume: dueToResume,
+    );
+    _deviceRefresh = future;
+    return future.whenComplete(() {
+      if (identical(_deviceRefresh, future)) _deviceRefresh = null;
+    });
+  }
+
+  Future<bool> _refreshDevices({
+    required bool requestPermission,
+    required bool force,
+    required bool dueToResume,
+  }) async {
+    final now = _clock();
+    final last = _lastDeviceRefreshAt;
+    if (!force &&
+        last != null &&
+        now.difference(last) < const Duration(seconds: 2)) {
+      return false;
+    }
+    _lastDeviceRefreshAt = now;
     _loadingDevices = true;
     _devicesError = null;
     _notify();
+
     try {
       if (requestPermission) {
         final granted = await _platform.ensureBluetoothPermission();
@@ -147,22 +288,95 @@ class ObdController extends ChangeNotifier {
           return false;
         }
       }
-      final enabled = await _platform.isBluetoothEnabled();
-      _bluetoothEnabled = enabled;
-      if (!enabled) {
+
+      final previousState = _bluetoothState;
+      final state = await _platform.bluetoothState();
+      _bluetoothState = state;
+      if (!previousState.isOn && state.isOn) {
+        // Bluetooth was turned off and on; allow one fresh connection attempt.
+        _connectionFailed = false;
+      }
+
+      if (state.isOn) {
+        if (dueToResume && !force && _devicesKnown) {
+          _needsBluetoothPermission = false;
+          return true;
+        }
+        final found = await _platform.listBondedDevices();
+        _devices = List.unmodifiable(found);
+        _devicesKnown = true;
+        _needsBluetoothPermission = false;
+        _bluetoothRetryAttempts = 0;
+        _bluetoothRetryTimer?.cancel();
+        _bluetoothRetryTimer = null;
+        return true;
+      }
+
+      if (state.isTransitioning) {
+        _devicesKnown = false;
+        _devicesError = null;
+        _needsBluetoothPermission = false;
+        _scheduleBluetoothRetry();
+        return false;
+      }
+
+      if (state.isOff) {
+        _devices = const [];
+        _devicesKnown = false;
+        _devicesError = null;
         _needsBluetoothPermission = false;
-        _devicesError = 'Bluetooth is turned off.';
+        _bluetoothRetryAttempts = 0;
+        _bluetoothRetryTimer?.cancel();
+        _bluetoothRetryTimer = null;
         return false;
       }
-      final found = await _platform.listBondedDevices();
-      _devices = List.unmodifiable(found);
+
+      if (state == BluetoothPowerState.unavailable) {
+        _devicesError = 'Bluetooth is not available on this device.';
+        _needsBluetoothPermission = false;
+        return false;
+      }
+
+      _devicesKnown = false;
+      _devicesError = 'Bluetooth is not responding.';
       _needsBluetoothPermission = false;
-      return true;
+      _scheduleBluetoothRetry();
+      return false;
     } on TelemetryException catch (problem) {
-      _needsBluetoothPermission = problem.message.toLowerCase().contains(
-        'permission',
-      );
-      _devicesError = problem.message;
+      if (problem.code == 'turning_on') {
+        _bluetoothState = BluetoothPowerState.turningOn;
+        _devicesKnown = false;
+        _devicesError = null;
+        _needsBluetoothPermission = false;
+        _scheduleBluetoothRetry();
+      } else if (problem.code == 'turning_off') {
+        _bluetoothState = BluetoothPowerState.turningOff;
+        _devicesKnown = false;
+        _devicesError = null;
+        _needsBluetoothPermission = false;
+        _scheduleBluetoothRetry();
+      } else if (problem.code == 'disabled') {
+        _bluetoothState = BluetoothPowerState.off;
+        _devices = const [];
+        _devicesKnown = false;
+        _devicesError = null;
+        _needsBluetoothPermission = false;
+      } else if (problem.code == 'permission') {
+        _needsBluetoothPermission = true;
+        _devicesKnown = false;
+        _devicesError = problem.message;
+      } else if (problem.code == 'unavailable') {
+        _bluetoothState = BluetoothPowerState.unavailable;
+        _devicesKnown = false;
+        _devicesError = problem.message;
+        _needsBluetoothPermission = false;
+      } else {
+        _needsBluetoothPermission = problem.message.toLowerCase().contains(
+          'permission',
+        );
+        _devicesKnown = false;
+        _devicesError = problem.message;
+      }
       return false;
     } finally {
       _loadingDevices = false;
@@ -174,18 +388,38 @@ class ObdController extends ChangeNotifier {
     return _platform.openBluetoothSettings();
   }
 
-  Future<void> _connect() async {
+  Future<bool> _connect() async {
     final address = _selectedAddress;
-    if (address == null) return;
+    if (address == null) return false;
+    _pollTimer?.cancel();
+    _pollTimer = null;
     _localError = null;
+    _connectionFailed = false;
+    var started = false;
     try {
       await _platform.connectObd(address);
+      started = true;
     } on TelemetryException catch (problem) {
       _localError = problem.message;
+      _needsBluetoothPermission = problem.code == 'permission';
+      if (problem.code == 'timeout' ||
+          problem.code == 'permission' ||
+          problem.code == 'disabled' ||
+          problem.code == 'unavailable') {
+        _connectionFailed = problem.code == 'timeout';
+        _pollTimer?.cancel();
+        _pollTimer = null;
+      }
     } catch (_) {
       _localError = 'Could not connect to the OBD adapter.';
     }
     _notify();
+    return started;
+  }
+
+  void _startPolling() {
+    _pollTimer?.cancel();
+    _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll());
   }
 
   Future<void> _poll() async {
@@ -199,6 +433,14 @@ class ObdController extends ChangeNotifier {
         _lastEconomyTimestamp = null;
       }
       _state = next;
+      if (next.connected) {
+        _connectionFailed = false;
+      }
+      if (next.retryExhausted) {
+        _connectionFailed = true;
+        _pollTimer?.cancel();
+        _pollTimer = null;
+      }
       if (next.connected && next.updatedAt > 0) {
         if (_lastEconomyTimestamp != next.updatedAt) {
           _lastEconomyTimestamp = next.updatedAt;
@@ -218,6 +460,30 @@ class ObdController extends ChangeNotifier {
     }
   }
 
+  BluetoothDeviceInfo? _deviceFor(String? address) {
+    if (address == null) return null;
+    for (final device in _devices) {
+      if (device.address == address) return device;
+    }
+    return null;
+  }
+
+  bool get _selectedDeviceIsUnsupported {
+    final device = _deviceFor(_selectedAddress);
+    return device != null && !device.isLikelyObd;
+  }
+
+  void _scheduleBluetoothRetry() {
+    _bluetoothRetryTimer?.cancel();
+    if (_disposed || _bluetoothRetryAttempts >= 3) return;
+    _bluetoothRetryAttempts++;
+    _bluetoothRetryTimer = Timer(const Duration(seconds: 2), () {
+      _bluetoothRetryTimer = null;
+      if (_disposed || !_active) return;
+      unawaited(refreshDevices(force: true));
+    });
+  }
+
   void _notify() {
     if (!_disposed) notifyListeners();
   }
@@ -226,6 +492,7 @@ class ObdController extends ChangeNotifier {
   void dispose() {
     _disposed = true;
     _pollTimer?.cancel();
+    _bluetoothRetryTimer?.cancel();
     unawaited(_platform.disconnectObd());
     super.dispose();
   }

+ 80 - 20
lib/features/settings/obd_settings_card.dart

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/widgets/app_icon.dart';
 import '../../data/models/bluetooth_device_info.dart';
+import '../../data/models/bluetooth_power_state.dart';
 import '../home/obd_controller.dart';
 
 /// Settings card for the optional Bluetooth Classic ELM327 adapter only.
@@ -18,6 +19,8 @@ class ObdSettingsCard extends StatefulWidget {
 
 class _ObdSettingsCardState extends State<ObdSettingsCard>
     with WidgetsBindingObserver {
+  bool _showAllDevices = false;
+
   @override
   void initState() {
     super.initState();
@@ -25,7 +28,10 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
     WidgetsBinding.instance.addPostFrameCallback((_) {
       if (!mounted) return;
       unawaited(
-        context.read<ObdController>().refreshDevices(requestPermission: false),
+        context.read<ObdController>().refreshDevices(
+          requestPermission: false,
+          force: true,
+        ),
       );
     });
   }
@@ -38,11 +44,11 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
 
   @override
   void didChangeAppLifecycleState(AppLifecycleState state) {
-    if (state == AppLifecycleState.resumed) {
-      unawaited(
-        context.read<ObdController>().refreshDevices(requestPermission: false),
-      );
-    }
+    if (state != AppLifecycleState.resumed || !mounted) return;
+    // The controller performs a cheap power-state check. It only lists bonded
+    // devices again when the cached list is missing, so returning from Android
+    // Settings does not hit the Bluetooth stack with a bond query every time.
+    unawaited(context.read<ObdController>().refreshDevices(dueToResume: true));
   }
 
   @override
@@ -52,7 +58,10 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
     final obd = context.watch<ObdController>();
 
     final selected = obd.selectedAddress;
-    final options = <BluetoothDeviceInfo>[...obd.devices];
+    final options = <BluetoothDeviceInfo>[
+      ...obd.obdDevices,
+      if (_showAllDevices) ...obd.otherDevices,
+    ];
     if (selected != null && !options.any((d) => d.address == selected)) {
       options.add(
         BluetoothDeviceInfo(name: 'Saved adapter', address: selected),
@@ -63,7 +72,7 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
 
     final statusColor = obd.connected
         ? colors.accent
-        : obd.error != null
+        : obd.error != null || obd.bluetoothState == BluetoothPowerState.unknown
         ? colors.danger
         : colors.inkFaint;
 
@@ -125,6 +134,7 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
                   : () async {
                       final ok = await obd.refreshDevices(
                         requestPermission: true,
+                        force: true,
                       );
                       if (!ok && context.mounted) {
                         _message(
@@ -146,7 +156,7 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
         ),
         const SizedBox(height: 12),
         DropdownButtonFormField<String?>(
-          key: ValueKey(dropdownValue),
+          key: ValueKey('${dropdownValue ?? 'none'}-$_showAllDevices'),
           initialValue: dropdownValue,
           isExpanded: true,
           dropdownColor: colors.surface,
@@ -167,22 +177,71 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
                 ),
               ),
           ],
-          onChanged: (value) async {
-            await obd.selectAdapter(value);
-            if (!context.mounted) return;
-            if (obd.error != null) {
-              _message(context, obd.error!);
-            }
-          },
+          onChanged: obd.loadingDevices
+              ? null
+              : (value) async {
+                  await obd.selectAdapter(value);
+                  if (!context.mounted) return;
+                  if (obd.error != null) {
+                    _message(context, obd.error!);
+                  }
+                },
         ),
-        if (obd.devicesError != null && !obd.needsBluetoothPermission) ...[
+        if (obd.otherDevices.isNotEmpty) ...[
+          const SizedBox(height: 2),
+          TextButton(
+            onPressed: () {
+              setState(() => _showAllDevices = !_showAllDevices);
+            },
+            style: TextButton.styleFrom(
+              foregroundColor: colors.accent,
+              padding: EdgeInsets.zero,
+              alignment: Alignment.centerLeft,
+            ),
+            child: Text(
+              _showAllDevices
+                  ? 'Show only OBD-II adapters'
+                  : 'Show all paired devices',
+            ),
+          ),
+          if (_showAllDevices)
+            Text(
+              'Phones, headsets and other devices usually cannot act as an '
+              'OBD-II adapter.',
+              style: theme.textTheme.bodySmall?.copyWith(
+                color: colors.inkMuted,
+              ),
+            ),
+        ],
+        if (obd.canRetry) ...[
+          const SizedBox(height: 2),
+          TextButton(
+            onPressed: () async {
+              await obd.retryConnection();
+              if (!context.mounted) return;
+              if (obd.error != null) {
+                _message(context, obd.error!);
+              }
+            },
+            style: TextButton.styleFrom(
+              foregroundColor: colors.accent,
+              padding: EdgeInsets.zero,
+              alignment: Alignment.centerLeft,
+            ),
+            child: const Text('Retry connection'),
+          ),
+        ],
+        if (obd.devicesError != null &&
+            !obd.needsBluetoothPermission &&
+            (obd.bluetoothState.isOn ||
+                obd.bluetoothState == BluetoothPowerState.unavailable)) ...[
           const SizedBox(height: 6),
           Text(
             obd.devicesError!,
             style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
           ),
         ],
-        if (obd.bluetoothEnabled == false) ...[
+        if (obd.bluetoothOff) ...[
           const SizedBox(height: 2),
           TextButton.icon(
             onPressed: () async {
@@ -206,8 +265,9 @@ class _ObdSettingsCardState extends State<ObdSettingsCard>
         ],
         const SizedBox(height: 8),
         Text(
-          'Pair the ELM327 in Android Bluetooth settings first. CarDash only '
-          'reads standard OBD-II PIDs; it never writes.',
+          'Only Bluetooth Classic OBD-II adapters are listed. Pair the ELM327 '
+          'in Android Bluetooth settings first. CarDash only reads standard '
+          'OBD-II PIDs; it never writes.',
           style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
         ),
       ],

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.0.5+17
+version: 1.0.6+18
 
 environment:
   sdk: ^3.13.7

+ 14 - 0
releases/README.md

@@ -1,5 +1,19 @@
 # CarDash releases
 
+## 1.0.6 — Bluetooth/OBD freeze hardening
+
+- **File:** `cardash-android-1.0.6.apk`
+- **Version:** 1.0.6 (versionCode 18)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `180050ad7cb7db88b4e253d748e49dedb380f0ed11acad743c39b4f925e9afed`
+- **Signing:** project debug key matching all releases.
+- **Changes:** moves every Bluetooth binder call off the Android main thread,
+  filters the OBD-II picker to likely OBD-II adapters, adds a bounded
+  three-attempt connection limit with a **Retry connection** action, stops
+  re-querying bonded devices on every resume, and adds timeouts around the
+  telemetry bridge. Intended to remove the freeze that could occur after
+  returning from Bluetooth settings with the OBD-II card open.
+
 ## 1.0.5 — Volume diagnostics
 
 - **File:** `cardash-android-1.0.5.apk`

BIN
releases/cardash-android-1.0.6.apk


+ 1 - 0
releases/cardash-android-1.0.6.apk.sha256

@@ -0,0 +1 @@
+180050ad7cb7db88b4e253d748e49dedb380f0ed11acad743c39b4f925e9afed  releases/cardash-android-1.0.6.apk

+ 2 - 1
test/gps_speed_test.dart

@@ -1,5 +1,6 @@
 import 'package:cardash_launcher/core/theme/app_theme.dart';
 import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
 import 'package:cardash_launcher/data/models/gps_speed.dart';
 import 'package:cardash_launcher/data/models/location_reading.dart';
 import 'package:cardash_launcher/data/models/obd_state.dart';
@@ -40,7 +41,7 @@ class _GpsPlatform implements TelemetryPlatform {
   Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
 
   @override
-  Future<bool> isBluetoothEnabled() async => false;
+  Future<BluetoothPowerState> bluetoothState() async => BluetoothPowerState.off;
 
   @override
   Future<bool> ensureBluetoothPermission() async => false;

+ 248 - 0
test/obd_controller_test.dart

@@ -0,0 +1,248 @@
+import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
+import 'package:cardash_launcher/data/models/gps_speed.dart';
+import 'package:cardash_launcher/data/models/location_reading.dart';
+import 'package:cardash_launcher/data/models/obd_state.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
+import 'package:cardash_launcher/features/home/obd_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+const _obdAddress = 'AA:BB:CC:DD:EE:01';
+const _phoneAddress = 'AA:BB:CC:DD:EE:02';
+
+const _obdAdapter = BluetoothDeviceInfo(
+  name: 'ELM327 v1.5',
+  address: _obdAddress,
+  deviceClass: 0x1F00,
+);
+
+const _phone = BluetoothDeviceInfo(
+  name: 'Pixel 8',
+  address: _phoneAddress,
+  deviceClass: 0x0200,
+);
+
+class _FakeTelemetry implements TelemetryPlatform {
+  BluetoothPowerState state = BluetoothPowerState.on;
+  List<BluetoothDeviceInfo> bonded = const [];
+  bool permissionGranted = true;
+  TelemetryException? connectError;
+  ObdState obdState = const ObdState();
+
+  int listCalls = 0;
+  int stateCalls = 0;
+  int connectCalls = 0;
+  String? connectedAddress;
+
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async {
+    listCalls++;
+    return bonded;
+  }
+
+  @override
+  Future<BluetoothPowerState> bluetoothState() async {
+    stateCalls++;
+    return state;
+  }
+
+  @override
+  Future<bool> ensureBluetoothPermission() async => permissionGranted;
+
+  @override
+  Future<bool> openBluetoothSettings() async => true;
+
+  @override
+  Future<bool> ensureLocationPermission() async => true;
+
+  @override
+  Future<LocationReading> getLocation() async =>
+      const LocationReading(latitude: 0, longitude: 0, place: 'Home');
+
+  @override
+  Future<void> connectObd(String address) async {
+    connectCalls++;
+    connectedAddress = address;
+    final problem = connectError;
+    if (problem != null) throw problem;
+  }
+
+  @override
+  Future<void> disconnectObd() async {}
+
+  @override
+  Future<ObdState> getObdState() async => obdState;
+
+  @override
+  Future<bool> startGpsSpeed() async => false;
+
+  @override
+  Future<void> stopGpsSpeed() async {}
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
+}
+
+Future<SettingsRepository> _settingsWithAdapter(String? address) async {
+  SharedPreferences.setMockInitialValues({});
+  final prefs = await SharedPreferences.getInstance();
+  final settings = SettingsRepository(prefs);
+  await settings.setObdAdapterAddress(address);
+  return settings;
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  test('classifies OBD adapters and non-OBD devices', () {
+    expect(_obdAdapter.isLikelyObd, isTrue);
+    expect(_phone.isLikelyObd, isFalse);
+
+    const headset = BluetoothDeviceInfo(
+      name: 'Car audio',
+      address: 'AA:BB:CC:DD:EE:03',
+      deviceClass: 0x0400,
+    );
+    expect(headset.isLikelyObd, isFalse);
+
+    const sppDevice = BluetoothDeviceInfo(
+      name: 'Unknown',
+      address: 'AA:BB:CC:DD:EE:04',
+      deviceClass: 0x0200,
+      uuids: ['00001101-0000-1000-8000-00805f9b34fb'],
+    );
+    expect(sppDevice.isLikelyObd, isTrue);
+
+    const unnamedClass = BluetoothDeviceInfo(
+      name: 'Unknown',
+      address: 'AA:BB:CC:DD:EE:05',
+    );
+    expect(unnamedClass.isLikelyObd, isTrue);
+
+    const parsed = BluetoothDeviceInfo(
+      name: 'Phone',
+      address: _phoneAddress,
+      deviceClass: 0x0200,
+    );
+    expect(parsed.majorClass, 0x0200);
+  });
+
+  test('splits paired devices into OBD and other lists', () async {
+    final settings = await _settingsWithAdapter(null);
+    final telemetry = _FakeTelemetry()..bonded = const [_obdAdapter, _phone];
+    final controller = ObdController(telemetry, settings);
+    addTearDown(controller.dispose);
+
+    expect(await controller.refreshDevices(force: true), isTrue);
+    expect(controller.devices, hasLength(2));
+    expect(controller.obdDevices.single.address, _obdAddress);
+    expect(controller.otherDevices.single.address, _phoneAddress);
+  });
+
+  test('resume checks power state but does not relist known devices', () async {
+    var now = DateTime(2026);
+    final settings = await _settingsWithAdapter(null);
+    final telemetry = _FakeTelemetry()..bonded = const [_obdAdapter];
+    final controller = ObdController(telemetry, settings, clock: () => now);
+    addTearDown(controller.dispose);
+
+    expect(await controller.refreshDevices(force: true), isTrue);
+    expect(telemetry.listCalls, 1);
+    expect(telemetry.stateCalls, 1);
+
+    now = now.add(const Duration(seconds: 5));
+    expect(await controller.refreshDevices(dueToResume: true), isTrue);
+    expect(telemetry.listCalls, 1);
+    expect(telemetry.stateCalls, 2);
+  });
+
+  test('turning on is reported without listing devices', () async {
+    final settings = await _settingsWithAdapter(null);
+    final telemetry = _FakeTelemetry()
+      ..state = BluetoothPowerState.turningOn
+      ..bonded = const [_obdAdapter];
+    final controller = ObdController(telemetry, settings);
+    addTearDown(controller.dispose);
+
+    expect(await controller.refreshDevices(force: true), isFalse);
+    expect(controller.bluetoothState, BluetoothPowerState.turningOn);
+    expect(telemetry.listCalls, 0);
+    expect(controller.statusLabel, 'Bluetooth is starting…');
+  });
+
+  test('denied Bluetooth permission clears the loading state', () async {
+    final settings = await _settingsWithAdapter(null);
+    final telemetry = _FakeTelemetry()..permissionGranted = false;
+    final controller = ObdController(telemetry, settings);
+    addTearDown(controller.dispose);
+
+    expect(
+      await controller.refreshDevices(requestPermission: true, force: true),
+      isFalse,
+    );
+    expect(controller.loadingDevices, isFalse);
+    expect(controller.needsBluetoothPermission, isTrue);
+    expect(telemetry.listCalls, 0);
+  });
+
+  test('start does not auto-connect to a saved non-OBD device', () async {
+    final settings = await _settingsWithAdapter(_phoneAddress);
+    final telemetry = _FakeTelemetry()..bonded = const [_phone];
+    final controller = ObdController(telemetry, settings)..initialize();
+    addTearDown(controller.dispose);
+
+    await controller.start();
+
+    expect(telemetry.connectCalls, 0);
+    expect(controller.error, contains('does not look like an OBD-II'));
+    expect(controller.canRetry, isTrue);
+
+    await controller.retryConnection();
+    controller.stop();
+    expect(telemetry.connectCalls, 1);
+  });
+
+  test('start connects a saved OBD adapter', () async {
+    final settings = await _settingsWithAdapter(_obdAddress);
+    final telemetry = _FakeTelemetry()..bonded = const [_obdAdapter];
+    final controller = ObdController(telemetry, settings)..initialize();
+    addTearDown(controller.dispose);
+
+    await controller.start();
+    controller.stop();
+
+    expect(telemetry.connectCalls, 1);
+    expect(telemetry.connectedAddress, _obdAddress);
+  });
+
+  test('connect timeout stops polling and exposes retry', () async {
+    final settings = await _settingsWithAdapter(_obdAddress);
+    final telemetry = _FakeTelemetry()
+      ..bonded = const [_obdAdapter]
+      ..connectError = TelemetryException(
+        'Bluetooth is not responding.',
+        code: 'timeout',
+      );
+    final controller = ObdController(telemetry, settings)..initialize();
+    addTearDown(controller.dispose);
+
+    await controller.start();
+    controller.stop();
+
+    expect(telemetry.connectCalls, 1);
+    expect(controller.canRetry, isTrue);
+    expect(controller.statusLabel, 'Not connected — adapter not responding');
+  });
+
+  test('parses retryExhausted from the native snapshot', () {
+    final state = ObdState.fromMap({
+      'connected': false,
+      'connecting': false,
+      'error': 'Could not connect.',
+      'retryExhausted': true,
+    });
+    expect(state.retryExhausted, isTrue);
+  });
+}

+ 120 - 0
test/obd_settings_card_test.dart

@@ -0,0 +1,120 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
+import 'package:cardash_launcher/data/models/gps_speed.dart';
+import 'package:cardash_launcher/data/models/location_reading.dart';
+import 'package:cardash_launcher/data/models/obd_state.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
+import 'package:cardash_launcher/features/home/obd_controller.dart';
+import 'package:cardash_launcher/features/settings/obd_settings_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+const _obd = BluetoothDeviceInfo(
+  name: 'ELM327',
+  address: 'AA:BB:CC:DD:EE:01',
+  deviceClass: 0x1F00,
+);
+const _phone = BluetoothDeviceInfo(
+  name: 'Pixel 8',
+  address: 'AA:BB:CC:DD:EE:02',
+  deviceClass: 0x0200,
+);
+
+class _CardTelemetry implements TelemetryPlatform {
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [
+    _obd,
+    _phone,
+  ];
+
+  @override
+  Future<BluetoothPowerState> bluetoothState() async => BluetoothPowerState.on;
+
+  @override
+  Future<bool> ensureBluetoothPermission() async => true;
+
+  @override
+  Future<bool> openBluetoothSettings() async => true;
+
+  @override
+  Future<bool> ensureLocationPermission() async => true;
+
+  @override
+  Future<LocationReading> getLocation() async =>
+      const LocationReading(latitude: 0, longitude: 0, place: '');
+
+  @override
+  Future<void> connectObd(String address) async {}
+
+  @override
+  Future<void> disconnectObd() async {}
+
+  @override
+  Future<ObdState> getObdState() async => const ObdState();
+
+  @override
+  Future<bool> startGpsSpeed() async => false;
+
+  @override
+  Future<void> stopGpsSpeed() async {}
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
+}
+
+void main() {
+  testWidgets('OBD card hides phones until Show all paired devices is tapped', (
+    tester,
+  ) async {
+    await tester.binding.setSurfaceSize(const Size(900, 700));
+    addTearDown(() => tester.binding.setSurfaceSize(null));
+
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final controller = ObdController(
+      _CardTelemetry(),
+      SettingsRepository(prefs),
+    );
+    addTearDown(controller.dispose);
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.light(),
+        home: ChangeNotifierProvider<ObdController>.value(
+          value: controller,
+          child: const Scaffold(
+            body: SizedBox(
+              width: 560,
+              child: Padding(
+                padding: EdgeInsets.all(24),
+                child: ObdSettingsCard(),
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+    await tester.pumpAndSettle();
+
+    expect(find.text('ELM327 · AA:BB:CC:DD:EE:01'), findsNothing);
+    expect(find.text('Show all paired devices'), findsOneWidget);
+
+    await tester.tap(find.text('None').first);
+    await tester.pumpAndSettle();
+    expect(find.text('ELM327 · AA:BB:CC:DD:EE:01'), findsOneWidget);
+    expect(find.text('Pixel 8 · AA:BB:CC:DD:EE:02'), findsNothing);
+
+    await tester.tap(find.text('None').last);
+    await tester.pumpAndSettle();
+    await tester.tap(find.text('Show all paired devices'));
+    await tester.pumpAndSettle();
+    await tester.tap(find.text('None').first);
+    await tester.pumpAndSettle();
+
+    expect(find.text('Pixel 8 · AA:BB:CC:DD:EE:02'), findsOneWidget);
+  });
+}

+ 2 - 1
test/settings_screen_test.dart

@@ -4,6 +4,7 @@ import 'package:cardash_launcher/core/theme/theme_controller.dart';
 import 'package:cardash_launcher/core/theme/theme_preference.dart';
 import 'package:cardash_launcher/data/models/app_entry.dart';
 import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
 import 'package:cardash_launcher/data/models/gps_speed.dart';
 import 'package:cardash_launcher/data/models/location_reading.dart';
 import 'package:cardash_launcher/data/models/media_playback.dart';
@@ -82,7 +83,7 @@ class _TelemetryPlatform implements TelemetryPlatform {
   Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
 
   @override
-  Future<bool> isBluetoothEnabled() async => true;
+  Future<BluetoothPowerState> bluetoothState() async => BluetoothPowerState.on;
 
   @override
   Future<bool> ensureBluetoothPermission() async => true;

+ 2 - 1
test/telemetry_settings_controller_test.dart

@@ -1,4 +1,5 @@
 import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
 import 'package:cardash_launcher/data/models/gps_speed.dart';
 import 'package:cardash_launcher/data/models/location_reading.dart';
 import 'package:cardash_launcher/data/models/obd_state.dart';
@@ -26,7 +27,7 @@ class _TelemetryPlatform implements TelemetryPlatform {
   Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
 
   @override
-  Future<bool> isBluetoothEnabled() async => true;
+  Future<BluetoothPowerState> bluetoothState() async => BluetoothPowerState.on;
 
   @override
   Future<bool> ensureBluetoothPermission() async => true;