diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3f99ba3dd9..238d675409 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -58,6 +58,7 @@ + diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt index e759c517fa..af88d62a3d 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt @@ -54,6 +54,7 @@ import com.bluebubbles.messaging.services.system.GetZenMode import com.bluebubbles.messaging.services.system.HeifDecoder import com.bluebubbles.messaging.services.system.HeifEncoder import com.bluebubbles.messaging.services.system.NativeSyncIsolateHandler +import com.bluebubbles.messaging.services.system.NearbyFindMyAccessoryHandler import com.bluebubbles.messaging.services.system.OpenSMSAppHandler import com.bluebubbles.messaging.services.system.RecentContactsRequestHandler import com.bluebubbles.messaging.services.system.ShizukuGrantPermissionHandler @@ -138,6 +139,8 @@ class MethodCallHandler { CircleProximitySessionHandler.tag -> CircleProximitySessionHandler().handleMethodCall(call, result, context) EnableBTHandler.tag -> EnableBTHandler().handleMethodCall(call, result, context) NativeSyncIsolateHandler.tag -> NativeSyncIsolateHandler().handleMethodCall(call, result, context) + NearbyFindMyAccessoryHandler.scanTag, + NearbyFindMyAccessoryHandler.playTag -> NearbyFindMyAccessoryHandler.instance.handleMethodCall(call, result, context) SMSLessAuthGateway.tag -> SMSLessAuthGateway().handleMethodCall(call, result, context) ShizukuGrantPermissionHandler.tag -> ShizukuGrantPermissionHandler().handleMethodCall(call, result, context) ProvisionNative.tag -> ProvisionNative().handleMethodCall(call, result, context) diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NearbyFindMyAccessoryHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NearbyFindMyAccessoryHandler.kt new file mode 100644 index 0000000000..7282337f6e --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NearbyFindMyAccessoryHandler.kt @@ -0,0 +1,574 @@ +package com.bluebubbles.messaging.services.system + +import android.Manifest +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.ParcelUuid +import com.bluebubbles.messaging.models.MethodCallHandlerImpl +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.Locale +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Foreground-only, user-triggered nearby sound support for compatible Find My trackers. + * + * The protocol compatibility was informed by the Apache-2.0 AirGuard project, especially + * its AppleFindMy model and BluetoothLeService. This is an original, deliberately narrow + * implementation: it never exposes addresses or advertisement bytes, and it does not run + * in the background or claim ownership of a discovered tracker. + */ +class NearbyFindMyAccessoryHandler private constructor() : MethodCallHandlerImpl() { + companion object { + const val scanTag = "scanNearbyFindMyAccessories" + const val playTag = "playNearbyFindMyAccessorySound" + + val instance: NearbyFindMyAccessoryHandler by lazy { NearbyFindMyAccessoryHandler() } + + private const val defaultScanDurationMs = 8_000L + private const val minScanDurationMs = 3_000L + private const val maxScanDurationMs = 15_000L + private const val tokenLifetimeMs = 60_000L + private const val soundDurationMs = 5_000L + private const val operationTimeoutMs = 20_000L + private const val cccdUuidString = "00002902-0000-1000-8000-00805f9b34fb" + private const val appleManufacturerId = 0x004C + private const val findMyAdvertisementType = 0x12 + private const val separatedFindMyAdvertisementType = 0x19 + + private val dultServiceUuid = UUID.fromString("15190001-12F4-C226-88ED-2AC5579F2A85") + private val dultCharacteristicUuid = UUID.fromString("8E0C0001-1D68-FB92-BF61-48377421680E") + private val findMyCharacteristicUuid = UUID.fromString("4F860003-943B-49EF-BED4-2F730304427A") + private val airtagServiceUuid = UUID.fromString("7DFC9000-7D1C-4951-86AA-8D9728F8D66C") + private val airtagCharacteristicUuid = UUID.fromString("7DFC9001-7D1C-4951-86AA-8D9728F8D66C") + } + + private enum class Protocol(val wireName: String) { + DULT("dult"), + FIND_MY("find_my"), + AIRTAG("airtag") + } + + private enum class CommandPhase { START, STOP } + + private data class TokenEntry( + val device: BluetoothDevice, + val protocol: Protocol, + val expiresAtMs: Long, + ) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val tokenLock = Any() + private val tokens = HashMap() + private var activeScan: ScanOperation? = null + + override fun handleMethodCall( + call: MethodCall, + result: MethodChannel.Result, + context: Context, + ) { + when (call.method) { + scanTag -> scan(call, result, context) + playTag -> play(call, result, context) + else -> result.error("NOT_IMPLEMENTED", "Unsupported nearby Find My method", null) + } + } + + private fun scan(call: MethodCall, result: MethodChannel.Result, context: Context) { + val once = OnceResult(result) + if (!isUsableForeground(context)) { + once.error("FOREGROUND_REQUIRED", "Nearby tracker actions require the app to be open", null) + return + } + + val permissionError = permissionError(context, requireConnect = true) + if (permissionError != null) { + once.error("PERMISSION_DENIED", permissionError, null) + return + } + + val adapter = bluetoothAdapter(context) + if (adapter == null) { + once.error("BLUETOOTH_UNAVAILABLE", "This device does not provide Bluetooth", null) + return + } + if (!adapter.isEnabled) { + once.error("BLUETOOTH_DISABLED", "Bluetooth is turned off", null) + return + } + + val scanner = try { + adapter.bluetoothLeScanner + } catch (_: SecurityException) { + null + } + if (scanner == null) { + once.error("BLUETOOTH_UNAVAILABLE", "Bluetooth scanning is unavailable", null) + return + } + + val requestedDuration = call.argument("scanDurationMs")?.toLong() ?: defaultScanDurationMs + val durationMs = requestedDuration.coerceIn(minScanDurationMs, maxScanDurationMs) + synchronized(tokenLock) { + expireTokensLocked(System.currentTimeMillis()) + if (activeScan != null) { + once.error("SCAN_IN_PROGRESS", "A nearby tracker scan is already running", null) + return + } + val operation = ScanOperation(context.applicationContext, scanner, once, durationMs) + activeScan = operation + operation.start() + } + } + + private fun play(call: MethodCall, result: MethodChannel.Result, context: Context) { + val once = OnceResult(result) + if (!isUsableForeground(context)) { + once.error("FOREGROUND_REQUIRED", "Nearby tracker actions require the app to be open", null) + return + } + + val permissionError = permissionError(context, requireConnect = true) + if (permissionError != null) { + once.error("PERMISSION_DENIED", permissionError, null) + return + } + + val token = call.argument("token")?.trim() + if (token.isNullOrEmpty()) { + once.error("INVALID_TOKEN", "A nearby tracker token is required", null) + return + } + + val entry = synchronized(tokenLock) { + expireTokensLocked(System.currentTimeMillis()) + tokens.remove(token) + } + if (entry == null) { + once.error("TOKEN_EXPIRED", "That nearby tracker result has expired", null) + return + } + + val adapter = bluetoothAdapter(context) + if (adapter == null) { + once.error("BLUETOOTH_UNAVAILABLE", "This device does not provide Bluetooth", null) + return + } + if (!adapter.isEnabled) { + once.error("BLUETOOTH_DISABLED", "Bluetooth is turned off", null) + return + } + + SoundGattSession(context.applicationContext, entry, once).start() + } + + private fun bluetoothAdapter(context: Context): BluetoothAdapter? { + val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return null + return try { + manager.adapter + } catch (_: SecurityException) { + null + } + } + + private fun permissionError(context: Context, requireConnect: Boolean): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { + return "Nearby device scan permission is not granted" + } + if (requireConnect && context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + return "Nearby device connection permission is not granted" + } + return null + } + + private fun isUsableForeground(context: Context): Boolean { + val activity = context as? Activity ?: return false + return !activity.isFinishing && (Build.VERSION.SDK_INT < 17 || !activity.isDestroyed) + } + + private fun expireTokensLocked(nowMs: Long) { + tokens.entries.removeIf { it.value.expiresAtMs <= nowMs } + } + + private inner class ScanOperation( + private val context: Context, + private val scanner: BluetoothLeScanner, + private val result: OnceResult, + private val durationMs: Long, + ) { + private val completed = AtomicBoolean(false) + private val devicesByAddress = HashMap() + private val timeout = Runnable { finishSuccess() } + private val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, scanResult: ScanResult) { + record(scanResult) + } + + override fun onBatchScanResults(results: MutableList) { + results.forEach(::record) + } + + override fun onScanFailed(errorCode: Int) { + finishError("SCAN_FAILED", "Bluetooth scan failed", errorCode) + } + } + + fun start() { + try { + scanner.startScan( + null, + ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setReportDelay(0L) + .build(), + callback, + ) + mainHandler.postDelayed(timeout, durationMs) + } catch (_: SecurityException) { + finishError("PERMISSION_DENIED", "Bluetooth scan permission is not granted", null) + } catch (_: Exception) { + finishError("BLUETOOTH_UNAVAILABLE", "Bluetooth scanning is unavailable", null) + } + } + + private fun record(scanResult: ScanResult) { + if (completed.get()) return + val protocol = protocolFor(scanResult.scanRecord) ?: return + try { + val address = scanResult.device.address + val previous = devicesByAddress[address] + if (previous == null || scanResult.rssi > previous.rssi) { + devicesByAddress[address] = DiscoveredDevice(scanResult.device, protocol, scanResult.rssi) + } + } catch (_: SecurityException) { + finishError("PERMISSION_DENIED", "Bluetooth connection permission is not granted", null) + } + } + + private fun finishSuccess() { + if (!completed.compareAndSet(false, true)) return + stop() + val now = System.currentTimeMillis() + val output = synchronized(tokenLock) { + expireTokensLocked(now) + devicesByAddress.values.sortedByDescending { it.rssi }.map { discovered -> + val token = UUID.randomUUID().toString() + tokens[token] = TokenEntry( + device = discovered.device, + protocol = discovered.protocol, + expiresAtMs = now + tokenLifetimeMs, + ) + mapOf( + "token" to token, + "protocol" to discovered.protocol.wireName, + "signal" to signalFor(discovered.rssi), + "rssi" to discovered.rssi, + ) + } + } + result.success(output) + synchronized(tokenLock) { + if (activeScan === this) activeScan = null + } + } + + private fun finishError(code: String, message: String, details: Any?) { + if (!completed.compareAndSet(false, true)) return + stop() + result.error(code, message, details) + synchronized(tokenLock) { + if (activeScan === this) activeScan = null + } + } + + private fun stop() { + mainHandler.removeCallbacks(timeout) + try { + scanner.stopScan(callback) + } catch (_: SecurityException) { + // The operation is already completing. Do not call the result twice. + } + } + } + + private data class DiscoveredDevice( + val device: BluetoothDevice, + val protocol: Protocol, + val rssi: Int, + ) + + private inner class SoundGattSession( + private val context: Context, + private val entry: TokenEntry, + private val result: OnceResult, + ) : BluetoothGattCallback() { + private val completed = AtomicBoolean(false) + private val callbackTimeout = Runnable { fail("GATT_TIMEOUT", "The nearby tracker did not respond") } + private var stopRunnable: Runnable? = null + private var gatt: BluetoothGatt? = null + private var characteristic: BluetoothGattCharacteristic? = null + private var activeProtocol = entry.protocol + private var commandPhase = CommandPhase.START + private var started = false + + fun start() { + try { + gatt = entry.device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE) + if (gatt == null) { + fail("GATT_UNAVAILABLE", "Could not connect to the nearby tracker") + return + } + mainHandler.postDelayed(callbackTimeout, operationTimeoutMs) + } catch (_: SecurityException) { + fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted") + } catch (_: Exception) { + fail("GATT_UNAVAILABLE", "Could not connect to the nearby tracker") + } + } + + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (completed.get()) return + if (status == 19 && activeProtocol == Protocol.AIRTAG && started) { + succeed() + return + } + if (status != BluetoothGatt.GATT_SUCCESS) { + fail("GATT_CONNECTION_FAILED", "The nearby tracker connection failed") + return + } + if (newState == BluetoothGatt.STATE_CONNECTED) { + try { + if (!gatt.discoverServices()) { + fail("GATT_DISCOVERY_FAILED", "Could not inspect the nearby tracker") + } + } catch (_: SecurityException) { + fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted") + } + } else if (newState == BluetoothGatt.STATE_DISCONNECTED && !started) { + fail("GATT_DISCONNECTED", "The nearby tracker disconnected before sounding") + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (completed.get()) return + if (status != BluetoothGatt.GATT_SUCCESS) { + fail("GATT_DISCOVERY_FAILED", "Could not inspect the nearby tracker") + return + } + + val protocols = listOf(entry.protocol) + Protocol.values().filter { it != entry.protocol } + val match = protocols.firstNotNullOfOrNull { protocol -> + findService(gatt, protocol)?.getCharacteristic(characteristicUuidFor(protocol))?.let { protocol to it } + } + if (match == null) { + fail("UNSUPPORTED_TRACKER", "The nearby tracker sound protocol is unavailable") + return + } + activeProtocol = match.first + val target = match.second + characteristic = target + + if (activeProtocol == Protocol.AIRTAG) { + writeCommand(byteArrayOf(0xAF.toByte())) + } else { + enableNotifications(gatt, target) + } + } + + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + if (completed.get()) return + if (descriptor.uuid.toString().equals(cccdUuidString, ignoreCase = true) && status == BluetoothGatt.GATT_SUCCESS) { + writeCommand(startCommandFor(activeProtocol)) + } else { + fail("GATT_NOTIFICATION_FAILED", "Could not prepare the nearby tracker sound command") + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + if (completed.get()) return + if (status != BluetoothGatt.GATT_SUCCESS) { + fail("GATT_WRITE_FAILED", "The nearby tracker rejected the sound command") + return + } + + if (commandPhase == CommandPhase.START) { + started = true + if (activeProtocol == Protocol.AIRTAG) { + // First-generation AirTags confirm the trigger by closing the GATT link + // with status 19. Keep the session open until that confirmation arrives. + return + } else { + commandPhase = CommandPhase.STOP + stopRunnable = Runnable { + if (!completed.get()) writeCommand(stopCommandFor(activeProtocol)) + } + mainHandler.postDelayed(stopRunnable!!, soundDurationMs) + } + } else { + succeed() + } + } + + private fun findService(gatt: BluetoothGatt, protocol: Protocol) = gatt.services.firstOrNull { service -> + when (protocol) { + Protocol.DULT -> service.uuid == dultServiceUuid + Protocol.FIND_MY -> service.uuid.toString().lowercase(Locale.US).contains("fd44") + Protocol.AIRTAG -> service.uuid == airtagServiceUuid + } + } + + private fun characteristicUuidFor(protocol: Protocol): UUID = when (protocol) { + Protocol.DULT -> dultCharacteristicUuid + Protocol.FIND_MY -> findMyCharacteristicUuid + Protocol.AIRTAG -> airtagCharacteristicUuid + } + + private fun startCommandFor(protocol: Protocol): ByteArray = when (protocol) { + Protocol.DULT -> byteArrayOf(0x00, 0x03) + Protocol.FIND_MY -> byteArrayOf(0x01, 0x00, 0x03) + Protocol.AIRTAG -> byteArrayOf(0xAF.toByte()) + } + + private fun stopCommandFor(protocol: Protocol): ByteArray = when (protocol) { + Protocol.DULT -> byteArrayOf(0x01, 0x03) + Protocol.FIND_MY -> byteArrayOf(0x01, 0x01, 0x03) + Protocol.AIRTAG -> byteArrayOf(0xAF.toByte()) + } + + private fun enableNotifications(gatt: BluetoothGatt, target: BluetoothGattCharacteristic) { + try { + if (!gatt.setCharacteristicNotification(target, true)) { + fail("GATT_NOTIFICATION_FAILED", "Could not enable nearby tracker notifications") + return + } + val descriptor = target.getDescriptor(UUID.fromString(cccdUuidString)) + if (descriptor == null) { + fail("GATT_NOTIFICATION_FAILED", "The nearby tracker has no notification control") + return + } + val cccdValue = if ( + (target.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 + ) BluetoothGattDescriptor.ENABLE_INDICATION_VALUE else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + val accepted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, cccdValue) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + descriptor.value = cccdValue + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } + if (!accepted) fail("GATT_NOTIFICATION_FAILED", "Could not prepare the nearby tracker sound command") + } catch (_: SecurityException) { + fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted") + } + } + + private fun writeCommand(value: ByteArray) { + val gatt = gatt ?: return fail("GATT_UNAVAILABLE", "The nearby tracker connection is gone") + val target = characteristic ?: return fail("GATT_UNAVAILABLE", "The nearby tracker sound channel is gone") + try { + val accepted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(target, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + target.value = value + @Suppress("DEPRECATION") + gatt.writeCharacteristic(target) + } + if (!accepted) fail("GATT_WRITE_FAILED", "The nearby tracker rejected the sound command") + } catch (_: SecurityException) { + fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted") + } + } + + private fun fail(code: String, message: String) { + if (!completed.compareAndSet(false, true)) return + cleanup() + result.error(code, message, null) + } + + private fun succeed() { + if (!completed.compareAndSet(false, true)) return + cleanup() + result.success(true) + } + + private fun cleanup() { + completed.set(true) + mainHandler.removeCallbacks(callbackTimeout) + stopRunnable?.let(mainHandler::removeCallbacks) + stopRunnable = null + val currentGatt = gatt + gatt = null + try { + currentGatt?.disconnect() + currentGatt?.close() + } catch (_: SecurityException) { + // Cleanup is best effort after the one result has been completed. + } + } + + } + + private class OnceResult(private val delegate: MethodChannel.Result) { + private val completed = AtomicBoolean(false) + + fun success(value: Any?) { + if (completed.compareAndSet(false, true)) delegate.success(value) + } + + fun error(code: String, message: String, details: Any?) { + if (completed.compareAndSet(false, true)) delegate.error(code, message, details) + } + } + + private fun protocolFor(scanRecord: android.bluetooth.le.ScanRecord?): Protocol? { + val normalized = (scanRecord?.serviceUuids ?: emptyList()) + .map { it.uuid.toString().lowercase(Locale.US) } + // Legacy/separated Find My advertisements often expose no service UUID. AirGuard's + // AppleFindMy detector identifies this Apple company-data format as type 0x12 with + // the separated/offline marker 0x19 in the second byte. Treat this only as a scan + // hint. GATT discovery below independently tries every supported sound protocol. + val appleFindMyAdvertisement = scanRecord + ?.getManufacturerSpecificData(appleManufacturerId) + ?.let { data -> + data.size >= 2 && + (data[0].toInt() and 0xFF) == findMyAdvertisementType && + (data[1].toInt() and 0xFF) == separatedFindMyAdvertisementType + } == true + return when { + normalized.any { it == dultServiceUuid.toString().lowercase(Locale.US) } -> Protocol.DULT + normalized.any { it.contains("fd44") } -> Protocol.FIND_MY + normalized.any { it == airtagServiceUuid.toString().lowercase(Locale.US) } -> Protocol.AIRTAG + appleFindMyAdvertisement -> Protocol.AIRTAG + else -> null + } + } + + private fun signalFor(rssi: Int): String = when { + rssi >= -60 -> "strong" + rssi >= -80 -> "medium" + else -> "weak" + } +} diff --git a/lib/app/layouts/findmy/findmy_page.dart b/lib/app/layouts/findmy/findmy_page.dart index 0dbf01cf7c..5649851874 100644 --- a/lib/app/layouts/findmy/findmy_page.dart +++ b/lib/app/layouts/findmy/findmy_page.dart @@ -29,12 +29,26 @@ import 'package:flutter_map_marker_popup/flutter_map_marker_popup.dart'; import 'package:get/get.dart' hide Response; import 'package:latlong2/latlong.dart'; import 'package:maps_launcher/maps_launcher.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:sliding_up_panel2/sliding_up_panel2.dart'; import 'package:tuple/tuple.dart'; import 'package:universal_io/io.dart'; import 'package:bluebubbles/src/rust/api/api.dart' as api; import 'package:url_launcher/url_launcher.dart'; +@visibleForTesting +bool canPlayNearbyFindMySound({required bool isAccessory, required bool isAndroid}) { + return isAccessory && isAndroid; +} + +@visibleForTesting +String nearbyTrackerSignalLabel(Map tracker) { + final signal = tracker["signal"]?.toString() ?? "unknown"; + final rssi = tracker["rssi"]; + final readable = signal.isEmpty ? "Unknown" : "${signal[0].toUpperCase()}${signal.substring(1)}"; + return rssi is num ? "$readable signal (${rssi.toInt()} dBm)" : "$readable signal"; +} + class FindMyPage extends StatefulWidget { FindMyPage({super.key, this.defaultFriend}); @@ -67,6 +81,7 @@ class _FindMyPageState extends OptimizedState with SingleTickerProvi bool refreshing2 = false; bool canRefresh = false; bool isInClique = true; + bool nearbyAccessoryBusy = false; Map<(double, double), Address> cachedAddresses = {}; @@ -78,6 +93,124 @@ class _FindMyPageState extends OptimizedState with SingleTickerProvi api.FindMyFriendsClientDefaultAnisetteProvider? fmfClient; api.FindMyPhoneClientDefaultAnisetteProvider? fmipClient; + String nearbyTrackerLabel(String protocol) { + switch (protocol) { + case "dult": + return "Compatible tracker"; + case "find_my": + return "Find My accessory"; + case "airtag": + return "AirTag"; + default: + return "Nearby tracker"; + } + } + + Future playNearbyAccessorySound() async { + if (nearbyAccessoryBusy || !Platform.isAndroid) return; + + final permissions = await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); + if (permissions.values.any((status) => !status.isGranted)) { + if (mounted) { + showSnackbar("Bluetooth required", "Allow nearby-device access to find and play a tracker sound."); + } + return; + } + + if (mounted) setState(() => nearbyAccessoryBusy = true); + try { + final raw = await mcs.invokeMethod("scanNearbyFindMyAccessories", {"scanDurationMs": 8000}); + final trackers = (raw as List?) + ?.whereType() + .map((entry) => entry.cast()) + .where((entry) => entry["token"] is String) + .toList() ?? + >[]; + if (!mounted) return; + if (trackers.isEmpty) { + showSnackbar("No tracker found", "Keep the accessory close to this phone and try again."); + return; + } + + final selected = await showDialog>( + context: context, + builder: (context) => AlertDialog( + title: const Text("Play a nearby tracker sound"), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Choose a nearby candidate. Rotating Bluetooth identifiers prevent Android from proving which map item it represents.", + ), + const SizedBox(height: 12), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: trackers.length, + itemBuilder: (context, index) { + final tracker = trackers[index]; + final protocol = tracker["protocol"]?.toString() ?? "unknown"; + return ListTile( + leading: const Icon(Icons.bluetooth_searching), + title: Text("${nearbyTrackerLabel(protocol)} candidate ${index + 1}"), + subtitle: Text(index == 0 + ? "Closest detected • ${nearbyTrackerSignalLabel(tracker)}" + : nearbyTrackerSignalLabel(tracker)), + trailing: const Icon(Icons.volume_up), + onTap: () => Navigator.of(context).pop(tracker), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("Cancel")), + ], + ), + ); + if (selected == null || !mounted) return; + + await mcs.invokeMethod("playNearbyFindMyAccessorySound", {"token": selected["token"]}); + if (mounted) showSnackbar("Find My", "Nearby sound command sent."); + } on PlatformException catch (e) { + Logger.warn("Nearby Find My sound failed (${e.code})"); + if (mounted) { + final message = e.code == "BLUETOOTH_DISABLED" + ? "Turn on Bluetooth and try again." + : e.code == "TOKEN_EXPIRED" + ? "That nearby tracker result expired. Scan again." + : "Could not play a sound on the nearby tracker."; + showSnackbar("Find My", message); + } + } catch (e, s) { + Logger.warn("Nearby Find My sound failed", error: e, trace: s); + if (mounted) showSnackbar("Find My", "Could not play a sound on the nearby tracker."); + } finally { + if (mounted) setState(() => nearbyAccessoryBusy = false); + } + } + + Widget nearbyAccessoryAction(FindMyDevice item) { + if (!canPlayNearbyFindMySound( + isAccessory: item.isConsideredAccessory, + isAndroid: Platform.isAndroid, + )) { + return const SizedBox.shrink(); + } + return IconButton( + tooltip: "Play Nearby Tracker Sound", + onPressed: nearbyAccessoryBusy ? null : playNearbyAccessorySound, + icon: nearbyAccessoryBusy + ? buildProgressIndicator(context, size: 18) + : const Icon(Icons.bluetooth_searching, size: 20), + ); + } + @override void initState() { super.initState(); @@ -674,22 +807,29 @@ class _FindMyPageState extends OptimizedState with SingleTickerProvi mapController.move(LatLng(item.location!.latitude!, item.location!.longitude!), 10); } : null, - trailing: item.location?.latitude != null && item.location?.longitude != null ? ButtonTheme( - minWidth: 1, - child: TextButton( - style: TextButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: context.theme.colorScheme.primaryContainer, - ), - onPressed: () async { - await MapsLauncher.launchCoordinates(item.location!.latitude!, item.location!.longitude!); - }, - child: const Icon( - Icons.directions, - size: 20 - ), - ), - ) : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + nearbyAccessoryAction(item), + if (item.location?.latitude != null && item.location?.longitude != null) + ButtonTheme( + minWidth: 1, + child: TextButton( + style: TextButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: context.theme.colorScheme.primaryContainer, + ), + onPressed: () async { + await MapsLauncher.launchCoordinates( + item.location!.latitude!, + item.location!.longitude!, + ); + }, + child: const Icon(Icons.directions, size: 20), + ), + ), + ], + ), onLongPress: () async { const encoder = JsonEncoder.withIndent(" "); final str = encoder.convert(item.toJson()); @@ -904,6 +1044,7 @@ class _FindMyPageState extends OptimizedState with SingleTickerProvi var tile = ListTile( title: Text(ss.settings.redactedMode.value ? "Device" : (item.name ?? "Unknown Device")), subtitle: Text(ss.settings.redactedMode.value ? "Location" : (item.address?.label ?? item.address?.mapItemFullAddress ?? "No location found")), + trailing: nearbyAccessoryAction(item), onTap: item.location?.latitude != null && item.location?.longitude != null ? () async { if (context.isPhone) { diff --git a/test/findmy_nearby_sound_test.dart b/test/findmy_nearby_sound_test.dart new file mode 100644 index 0000000000..cf36067dfc --- /dev/null +++ b/test/findmy_nearby_sound_test.dart @@ -0,0 +1,20 @@ +import 'package:bluebubbles/app/layouts/findmy/findmy_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('nearby Find My sound', () { + test('is limited to Android accessory rows', () { + expect(canPlayNearbyFindMySound(isAccessory: true, isAndroid: true), isTrue); + expect(canPlayNearbyFindMySound(isAccessory: true, isAndroid: false), isFalse); + expect(canPlayNearbyFindMySound(isAccessory: false, isAndroid: true), isFalse); + }); + + test('shows RSSI when the scan provides it', () { + expect( + nearbyTrackerSignalLabel({'signal': 'strong', 'rssi': -47}), + 'Strong signal (-47 dBm)', + ); + expect(nearbyTrackerSignalLabel({'signal': 'weak'}), 'Weak signal'); + }); + }); +}