|
|
@@ -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)
|