From 7e6867a5167b43bdd8c98c86cf9f09e4b570675e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 01:04:28 +0530 Subject: [PATCH 1/9] feat: implement BLE support --- app/src/main/AndroidManifest.xml | 7 + .../java/com/sameerasw/airsync/AirSyncApp.kt | 5 + .../airsync/data/ble/BleChunkUtil.kt | 115 +++++ .../airsync/data/ble/BleConnectionManager.kt | 77 +++ .../airsync/data/ble/BleConstants.kt | 50 ++ .../airsync/data/ble/BleGattServer.kt | 465 ++++++++++++++++++ .../airsync/data/ble/BleTransportBridge.kt | 96 ++++ .../airsync/data/local/DataStoreManager.kt | 15 + .../airsync/domain/model/MacDeviceStatus.kt | 1 + .../sameerasw/airsync/domain/model/UiState.kt | 3 +- .../ui/activities/PermissionsActivity.kt | 21 + .../ui/components/SettingsView.kt | 3 + .../ui/components/cards/BleSyncCard.kt | 60 +++ .../ui/components/dialogs/PermissionDialog.kt | 11 +- .../ui/screens/PermissionsScreen.kt | 14 + .../viewmodel/AirSyncViewModel.kt | 39 +- .../airsync/service/AirSyncTileService.kt | 5 + .../airsync/service/MacMediaPlayerService.kt | 18 +- .../service/MediaNotificationListener.kt | 48 +- .../com/sameerasw/airsync/utils/JsonUtil.kt | 8 +- .../airsync/utils/MacDeviceStatusManager.kt | 81 ++- .../sameerasw/airsync/utils/PermissionUtil.kt | 21 + .../sameerasw/airsync/utils/SyncManager.kt | 67 ++- .../airsync/utils/WebSocketMessageHandler.kt | 1 + .../sameerasw/airsync/utils/WebSocketUtil.kt | 109 +++- .../res/drawable/rounded_bluetooth_24.xml | 20 + .../rounded_bluetooth_searching_24.xml | 20 + 27 files changed, 1306 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt create mode 100644 app/src/main/res/drawable/rounded_bluetooth_24.xml create mode 100644 app/src/main/res/drawable/rounded_bluetooth_searching_24.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 973848ef..4030914d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,13 @@ + + + + + + + diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 085c026f..a01c427b 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -10,16 +10,21 @@ import kotlinx.coroutines.runBlocking class AirSyncApp : Application() { private var activityCount = 0 + private lateinit var bleConnectionManager: com.sameerasw.airsync.data.ble.BleConnectionManager companion object { private var instance: AirSyncApp? = null fun isAppForeground(): Boolean = instance?.isForeground() ?: false + fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = instance?.bleConnectionManager } override fun onCreate() { super.onCreate() instance = this initSentry() + + bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this) + bleConnectionManager.start() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) { diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt new file mode 100644 index 00000000..7504492c --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt @@ -0,0 +1,115 @@ +package com.sameerasw.airsync.data.ble + +import android.util.Log + +object BleChunkUtil { + private const val TAG = "BleChunkUtil" + + /** + * Splits a string payload into chunks suitable for BLE transmission. + * Each chunk starts with a 2-byte header: [currentIndex, totalChunks] + */ + fun splitIntoChunks(payload: String, mtu: Int): List { + val data = payload.toByteArray(Charsets.UTF_8) + val maxPayloadSize = mtu - BleConstants.CHUNK_HEADER_SIZE + + if (maxPayloadSize <= 0) { + Log.e(TAG, "MTU too small: $mtu") + return emptyList() + } + + val totalChunks = (data.size + maxPayloadSize - 1) / maxPayloadSize + val chunks = mutableListOf() + + for (i in 0 until totalChunks) { + val start = i * maxPayloadSize + val end = minOf(start + maxPayloadSize, data.size) + val chunkData = data.sliceArray(start until end) + + val chunk = ByteArray(BleConstants.CHUNK_HEADER_SIZE + chunkData.size) + val buffer = java.nio.ByteBuffer.wrap(chunk) + buffer.putShort(i.toShort()) + buffer.putShort(totalChunks.toShort()) + + chunkData.copyInto(chunk, BleConstants.CHUNK_HEADER_SIZE) + chunks.add(chunk) + } + + return chunks + } + + /** + * Reassembles chunks into the original string. + * Expects a map of index to chunk data (without the header). + */ + fun reassemble(chunks: Map): String { + val sortedIndices = chunks.keys.sorted() + if (sortedIndices.isEmpty()) return "" + + val totalSize = chunks.values.sumOf { it.size } + val result = ByteArray(totalSize) + + var offset = 0 + for (index in sortedIndices) { + val chunk = chunks[index] ?: continue + chunk.copyInto(result, offset) + offset += chunk.size + } + + return String(result, Charsets.UTF_8) + } + + /** + * Extracts header information from a raw BLE packet. + */ + fun parseHeader(packet: ByteArray): Pair? { + if (packet.size < BleConstants.CHUNK_HEADER_SIZE) return null + val buffer = java.nio.ByteBuffer.wrap(packet) + val current = buffer.short.toInt() and 0xFFFF + val total = buffer.short.toInt() and 0xFFFF + return Pair(current, total) + } + + /** + * Extracts payload data from a raw BLE packet (strips header). + */ + fun getPayload(packet: ByteArray): ByteArray { + if (packet.size <= BleConstants.CHUNK_HEADER_SIZE) return byteArrayOf() + return packet.sliceArray(BleConstants.CHUNK_HEADER_SIZE until packet.size) + } + + /** + * Helper class to reassemble chunks as they arrive. + */ + class Reassembler { + private val chunks = mutableMapOf() + private var totalChunks = -1 + + fun addChunk(packet: ByteArray): String? { + val header = parseHeader(packet) ?: return null + val current = header.first + val total = header.second + + if (totalChunks != -1 && totalChunks != total) { + // New transmission started or mismatch, reset + chunks.clear() + } + totalChunks = total + + chunks[current] = getPayload(packet) + + if (chunks.size == totalChunks) { + val result = reassemble(chunks) + chunks.clear() + totalChunks = -1 + return result + } + return null + } + + fun clear() { + chunks.clear() + totalChunks = -1 + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt new file mode 100644 index 00000000..92b66e3b --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt @@ -0,0 +1,77 @@ +package com.sameerasw.airsync.data.ble + +import android.content.Context +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.WebSocketUtil +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +class BleConnectionManager(private val context: Context) { + companion object { + private const val TAG = "BleConnectionManager" + } + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val dataStoreManager = DataStoreManager(context) + private var bleServer: BleGattServer? = null + + private var isBleEnabled = false + + @OptIn(ExperimentalCoroutinesApi::class) + private val _serverFlow = kotlinx.coroutines.flow.MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val connectionState = _serverFlow.flatMapLatest { server -> + server?.connectionState ?: kotlinx.coroutines.flow.MutableStateFlow(BleGattServer.BleConnectionState.DISCONNECTED) + } + + fun start() { + if (bleServer == null) { + bleServer = BleGattServer(context) + _serverFlow.value = bleServer + BleTransportBridge.initialize(bleServer!!) + } + + scope.launch { + combine( + dataStoreManager.getBleSyncEnabled(), + dataStoreManager.getBleAutoConnectEnabled(), + WebSocketUtil.connectionState + ) { enabled, auto, wsState -> + Triple(enabled, auto, wsState) + }.collectLatest { (enabled, _, _) -> + isBleEnabled = enabled + updateBleState() + } + } + } + + private fun updateBleState() { + if (isBleEnabled) { + Log.d(TAG, "BLE enabled, starting GATT server") + bleServer?.start() + } else { + Log.d(TAG, "BLE disabled, stopping server") + bleServer?.stop() + } + } + + fun stop() { + scope.cancel() + bleServer?.stop() + } + + val isAuthenticated: Boolean + get() = bleServer?.isAuthenticated ?: false + + fun sendChunkedNotification(characteristicUuid: java.util.UUID, payload: String) { + bleServer?.sendChunkedNotification(characteristicUuid, payload) + } + + fun sendNotification(characteristicUuid: java.util.UUID, data: ByteArray) { + bleServer?.sendNotification(characteristicUuid, data) + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt new file mode 100644 index 00000000..78d5c7c9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt @@ -0,0 +1,50 @@ +package com.sameerasw.airsync.data.ble + +import java.util.UUID + +object BleConstants { + private const val UUID_BASE = "-7461-4694-8146-2162624a682c" + + // Services + val SERVICE_SYSTEM = UUID.fromString("a1520001$UUID_BASE") + val SERVICE_NOTIFICATIONS = UUID.fromString("a1520002$UUID_BASE") + val SERVICE_MEDIA = UUID.fromString("a1520003$UUID_BASE") + val SERVICE_CLIPBOARD = UUID.fromString("a1520004$UUID_BASE") + + // System Characteristics + val CHAR_PROTOCOL_VERSION = UUID.fromString("a1520101$UUID_BASE") + val CHAR_AUTH_TOKEN = UUID.fromString("a1520102$UUID_BASE") + val CHAR_AUTH_RESULT = UUID.fromString("a1520103$UUID_BASE") + val CHAR_BATTERY_LEVEL = UUID.fromString("a1520104$UUID_BASE") + val CHAR_MAC_BATTERY = UUID.fromString("a1520105$UUID_BASE") + val CHAR_SYSTEM_STATE = UUID.fromString("a1520106$UUID_BASE") + val CHAR_MAC_CONTROL = UUID.fromString("a1520107$UUID_BASE") + val CHAR_DEVICE_NAME = UUID.fromString("a1520108$UUID_BASE") + + // Notification Characteristics + val CHAR_NOTIFICATION_DATA = UUID.fromString("a1520201$UUID_BASE") + val CHAR_NOTIFICATION_ACTION = UUID.fromString("a1520202$UUID_BASE") + val CHAR_NOTIFICATION_DISMISS = UUID.fromString("a1520203$UUID_BASE") + val CHAR_NOTIFICATION_DISMISS_NOTIFY = UUID.fromString("a1520204$UUID_BASE") + + // Media Characteristics + val CHAR_MEDIA_STATE = UUID.fromString("a1520301$UUID_BASE") + val CHAR_MEDIA_CONTROL = UUID.fromString("a1520302$UUID_BASE") + val CHAR_MAC_MEDIA_STATE = UUID.fromString("a1520303$UUID_BASE") + + // Clipboard Characteristics + val CHAR_CLIPBOARD_DATA_NOTIFY = UUID.fromString("a1520401$UUID_BASE") + val CHAR_CLIPBOARD_DATA_WRITE = UUID.fromString("a1520402$UUID_BASE") + + // Protocol Constants + const val PROTOCOL_VERSION = 1 + const val AUTH_SUCCESS: Byte = 0x01 + const val AUTH_FAILED: Byte = 0x00 + + // Chunking + const val MAX_MTU = 512 + const val CHUNK_HEADER_SIZE = 4 // [index: UInt16][total: UInt16] + + // Delimiter for compact strings + const val DELIMITER = "\u001F" +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt new file mode 100644 index 00000000..7e1330dc --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -0,0 +1,465 @@ +package com.sameerasw.airsync.data.ble + +import android.annotation.SuppressLint +import android.bluetooth.* +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.content.Context +import android.os.ParcelUuid +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.NotificationDismissalUtil +import com.sameerasw.airsync.utils.ClipboardSyncManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue + +@SuppressLint("MissingPermission") +class BleGattServer(private val context: Context) { + companion object { + private const val TAG = "BleGattServer" + private var instance: BleGattServer? = null + fun isAnyAuthenticated(): Boolean = instance?.isAuthenticated ?: false + } + + init { + instance = this + } + + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val adapter = bluetoothManager.adapter + private var gattServer: BluetoothGattServer? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dataStoreManager = DataStoreManager(context) + + private val _connectionState = MutableStateFlow(BleConnectionState.DISCONNECTED) + val connectionState = _connectionState.asStateFlow() + + private val connectedDevices = mutableSetOf() + private val characteristicQueues = mutableMapOf>() + private val isSending = mutableMapOf() + + var isAuthenticated = false + private set + private var negotiatedMtu = 23 + + enum class BleConnectionState { + DISCONNECTED, ADVERTISING, CONNECTED, AUTHENTICATED + } + + private val pendingServices = mutableListOf() + + /** + * Start the GATT server and begin advertising + */ + fun start() { + if (_connectionState.value != BleConnectionState.DISCONNECTED) { + Log.d(TAG, "BLE GATT Server already starting or started") + return + } + + if (!com.sameerasw.airsync.utils.PermissionUtil.isBluetoothPermissionsGranted(context)) { + Log.e(TAG, "Missing Bluetooth permissions, cannot start BLE transport") + return + } + + if (adapter == null || !adapter.isEnabled) { + Log.e(TAG, "Bluetooth adapter not available or disabled") + return + } + + setupGattServer() + } + + /** + * Stop the GATT server and advertising + */ + fun stop() { + stopAdvertising() + gattServer?.clearServices() + gattServer?.close() + gattServer = null + connectedDevices.clear() + pendingServices.clear() + _connectionState.value = BleConnectionState.DISCONNECTED + isAuthenticated = false + } + + private fun setupGattServer() { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + + pendingServices.clear() + + // System Service + val systemService = BluetoothGattService(BleConstants.SERVICE_SYSTEM, BluetoothGattService.SERVICE_TYPE_PRIMARY) + systemService.addCharacteristic(createReadCharacteristic(BleConstants.CHAR_PROTOCOL_VERSION)) + systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_AUTH_TOKEN)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_AUTH_RESULT)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_BATTERY_LEVEL)) + systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_BATTERY)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_SYSTEM_STATE)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MAC_CONTROL)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_DEVICE_NAME)) + pendingServices.add(systemService) + + // Notifications Service + val notifService = BluetoothGattService(BleConstants.SERVICE_NOTIFICATIONS, BluetoothGattService.SERVICE_TYPE_PRIMARY) + notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DATA)) + notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_ACTION)) + notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS)) + notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY)) + pendingServices.add(notifService) + + // Media Service + val mediaService = BluetoothGattService(BleConstants.SERVICE_MEDIA, BluetoothGattService.SERVICE_TYPE_PRIMARY) + mediaService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MEDIA_STATE)) + mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MEDIA_CONTROL)) + mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_MEDIA_STATE)) + pendingServices.add(mediaService) + + // Clipboard Service + val clipService = BluetoothGattService(BleConstants.SERVICE_CLIPBOARD, BluetoothGattService.SERVICE_TYPE_PRIMARY) + clipService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY)) + clipService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_WRITE)) + pendingServices.add(clipService) + + // Add first service with a small delay for stability + scope.launch(Dispatchers.Main) { + delay(300) + if (pendingServices.isNotEmpty()) { + val first = pendingServices.removeAt(0) + gattServer?.addService(first) + } + } + } + + private var currentAdvertiseCallback: AdvertiseCallback? = null + + private fun startAdvertising() { + if (currentAdvertiseCallback != null) { + stopAdvertising() + } + + val advertiser = adapter.bluetoothLeAdvertiser ?: return + + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceUuid(ParcelUuid(BleConstants.SERVICE_SYSTEM)) + .build() + + val scanResponse = AdvertiseData.Builder() + .setIncludeDeviceName(true) + .build() + + val callback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + Log.d(TAG, "BLE Advertising started successfully") + _connectionState.value = BleConnectionState.ADVERTISING + } + + override fun onStartFailure(errorCode: Int) { + if (errorCode == ADVERTISE_FAILED_ALREADY_STARTED) { + Log.d(TAG, "BLE Advertising already started, treating as success") + _connectionState.value = BleConnectionState.ADVERTISING + } else { + Log.e(TAG, "BLE Advertising failed: $errorCode") + currentAdvertiseCallback = null + _connectionState.value = BleConnectionState.DISCONNECTED + } + } + } + + currentAdvertiseCallback = callback + advertiser.startAdvertising(settings, data, scanResponse, callback) + } + + private fun stopAdvertising() { + val callback = currentAdvertiseCallback ?: return + adapter.bluetoothLeAdvertiser?.stopAdvertising(callback) + currentAdvertiseCallback = null + } + + private val gattServerCallback = object : BluetoothGattServerCallback() { + override fun onServiceAdded(status: Int, service: BluetoothGattService) { + Log.d(TAG, "Service added: ${service.uuid}, status: $status") + if (pendingServices.isNotEmpty()) { + val next = pendingServices.removeAt(0) + gattServer?.addService(next) + } else { + Log.d(TAG, "All services added, starting advertising") + startAdvertising() + } + } + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + Log.d(TAG, "onConnectionStateChange: device=${device.address}, status=$status, newState=$newState, bond=${device.bondState}") + + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Device connected: ${device.address}") + connectedDevices.add(device) + _connectionState.value = BleConnectionState.CONNECTED + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Device disconnected: ${device.address}") + connectedDevices.remove(device) + if (connectedDevices.isEmpty()) { + _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED + isAuthenticated = false + } + } + } + + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) { + Log.d(TAG, "MTU changed for ${device.address}: $mtu") + negotiatedMtu = mtu + } + + override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic) { + if (characteristic.uuid == BleConstants.CHAR_PROTOCOL_VERSION) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, byteArrayOf(BleConstants.PROTOCOL_VERSION.toByte())) + } else { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, 0, null) + } + } + + private val chunkBuffers = mutableMapOf>() + + override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}") + + when (characteristic.uuid) { + BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) + BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) + BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(characteristic.uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(characteristic.uuid, value) { handleMacMediaState(it) } + BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(characteristic.uuid, value) { + Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}") + ClipboardSyncManager.handleClipboardUpdate(context, it) + } + BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(characteristic.uuid, value) { + Log.d(TAG, "Received Mac Device Name: $it") + // Update Mac name in status manager + MacDeviceStatusManager.updateMacStatus(context, name = it) + } + BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationDismiss(it) } + else -> Log.w(TAG, "Unknown characteristic write: ${characteristic.uuid}") + } + + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + + private fun handleChunkedWrite(uuid: UUID, value: ByteArray, onComplete: (String) -> Unit) { + val header = BleChunkUtil.parseHeader(value) + if (header == null) { + // Not chunked or invalid header - maybe small payload? + // For now, assume everything to these characteristics is chunked. + return + } + val (current, total) = header + val payload = BleChunkUtil.getPayload(value) + + val buffer = chunkBuffers.getOrPut(uuid) { mutableMapOf() } + buffer[current] = payload + + if (buffer.size == total) { + val completePayload = BleChunkUtil.reassemble(buffer) + chunkBuffers.remove(uuid) + onComplete(completePayload) + } + } + + override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, descriptor: BluetoothGattDescriptor) { + Log.d(TAG, "Descriptor read request: ${descriptor.uuid}") + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) + } + + override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + Log.d(TAG, "Descriptor write request: ${descriptor.uuid}, value: ${value.contentToString()}") + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + // This is crucial for sequential chunk sending + processNextInQueues() + } + } + + private fun handleAuthRequest(device: BluetoothDevice, token: ByteArray) { + scope.launch { + val deviceData = dataStoreManager.getLastConnectedDevice().first() + val storedKey = deviceData?.symmetricKey + Log.d(TAG, "Handling auth request from ${device.address}. Device in DB: ${deviceData?.name}, hasKey: ${storedKey != null}") + + if (storedKey != null) { + val expectedToken = BleTransportBridge.deriveAuthToken(storedKey) + val receivedTokenStr = String(token, Charsets.UTF_8) + + Log.d(TAG, "Expected token: $expectedToken") + Log.d(TAG, "Received token: $receivedTokenStr") + + if (token.contentEquals(expectedToken.toByteArray(Charsets.UTF_8))) { + Log.i(TAG, "BLE Auth Success!") + isAuthenticated = true + _connectionState.value = BleConnectionState.AUTHENTICATED + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS)) + BleTransportBridge.sendDeviceName() + } else { + Log.w(TAG, "BLE Auth Failed! Token mismatch.") + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + } + } else { + Log.w(TAG, "BLE Auth Failed! No symmetric key found for last connected device.") + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + } + } + } + + private fun handleNotificationDismiss(id: String) { + Log.d(TAG, "Handling notification dismissal from BLE: $id") + NotificationDismissalUtil.dismissNotification(id) + } + + private fun handleMacBattery(value: ByteArray) { + if (!isAuthenticated) return + val payload = String(value, Charsets.UTF_8) + val parts = payload.split("|") + if (parts.size >= 2) { + val level = parts[0].toIntOrNull() ?: -1 + val isCharging = parts[1] == "1" + Log.d(TAG, "Received Mac battery level via BLE: $level%, charging: $isCharging") + MacDeviceStatusManager.updateBatteryStatus(context, level, isCharging) + } else if (value.size == 1) { + // Legacy 1-byte fallback + val level = value[0].toInt() and 0xFF + MacDeviceStatusManager.updateBatteryStatus(context, level, false) + } + } + + private fun handleMacMediaState(payload: String) { + if (!isAuthenticated) return + val parts = payload.split("|") + if (parts.size >= 6) { + val isPlaying = parts[0] == "1" + val title = parts[1] + val artist = parts[2] + val volume = parts[3].toIntOrNull() ?: 0 + val isMuted = parts[4] == "1" + val likeStatus = parts[5] + + Log.d(TAG, "Received Mac media state via BLE: $title by $artist (Playing: $isPlaying)") + MacDeviceStatusManager.updateMusicStatus( + context, isPlaying, title, artist, volume, isMuted, likeStatus + ) + } + } + + private fun handleNotificationAction(value: ByteArray) { + if (!isAuthenticated) return + val data = String(value, Charsets.UTF_8) + BleTransportBridge.handleNotificationAction(data, context) + } + + private fun handleMediaControl(value: ByteArray) { + if (!isAuthenticated) return + val action = String(value, Charsets.UTF_8) + BleTransportBridge.handleMediaControl(action, context) + } + + /** + * Send a notification to all connected devices (with chunking) + */ + fun sendNotification(characteristicUuid: UUID, data: ByteArray) { + if (connectedDevices.isEmpty()) return + + // Characteristic level queue to ensure order + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } + queue.add(data) + + if (isSending[characteristicUuid] != true) { + processNextInQueue(characteristicUuid) + } + } + + fun sendChunkedNotification(characteristicUuid: UUID, payload: String) { + if (connectedDevices.isEmpty()) return + + // Truncate notification text to conserve BLE bandwidth + val truncatedPayload = if (characteristicUuid == BleConstants.CHAR_NOTIFICATION_DATA) { + payload.take(500) + } else payload + + val mtu = negotiatedMtu + val chunks = BleChunkUtil.splitIntoChunks(truncatedPayload, mtu) + + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } + chunks.forEach { queue.add(it) } + + if (isSending[characteristicUuid] != true) { + processNextInQueue(characteristicUuid) + } + } + + private fun processNextInQueues() { + characteristicQueues.keys.forEach { uuid -> + if (isSending[uuid] == true) { + isSending[uuid] = false + processNextInQueue(uuid) + } + } + } + + private fun processNextInQueue(uuid: UUID) { + val queue = characteristicQueues[uuid] ?: return + val data = queue.poll() ?: return + + isSending[uuid] = true + + val characteristic = findCharacteristic(uuid) ?: return + characteristic.value = data + + connectedDevices.forEach { device -> + gattServer?.notifyCharacteristicChanged(device, characteristic, false) + } + } + + private fun findCharacteristic(uuid: UUID): BluetoothGattCharacteristic? { + gattServer?.services?.forEach { service -> + service.getCharacteristic(uuid)?.let { return it } + } + return null + } + + private fun createReadCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + return BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ) + } + + private fun createWriteCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + return BluetoothGattCharacteristic(uuid, + BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE) + } + + private fun createNotifyCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + val char = BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ) + // Add CCCD for notification support + val configDescriptor = BluetoothGattDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE) + char.addDescriptor(configDescriptor) + return char + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt new file mode 100644 index 00000000..f3c0c847 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -0,0 +1,96 @@ +package com.sameerasw.airsync.data.ble + +import android.util.Log +import com.sameerasw.airsync.domain.model.BatteryInfo +import com.sameerasw.airsync.domain.model.AudioInfo +import java.security.MessageDigest +import java.util.* + +object BleTransportBridge { + private const val TAG = "BleTransportBridge" + + private var gattServer: BleGattServer? = null + + fun initialize(server: BleGattServer) { + gattServer = server + } + + fun deriveAuthToken(symmetricKey: String): String { + return try { + val md = MessageDigest.getInstance("SHA-256") + val hash = md.digest(symmetricKey.toByteArray(Charsets.UTF_8)) + Base64.getEncoder().encodeToString(hash.copyOf(16)) + } catch (e: Exception) { + Log.e(TAG, "Error deriving auth token: ${e.message}") + "" + } + } + + // --- Outbound (Android -> Mac) --- + + fun sendNotification(pkg: String, title: String, text: String) { + val payload = listOf(pkg, title, text).joinToString(BleConstants.DELIMITER) + gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DATA, payload) + } + + fun sendBatteryStatus(battery: BatteryInfo) { + val level = battery.level.toByte() + gattServer?.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level)) + } + + fun sendMediaState(audio: AudioInfo) { + val payload = listOf( + if (audio.isPlaying) "1" else "0", + audio.title, + audio.artist, + audio.volume.toString(), + if (audio.isMuted) "1" else "0", + audio.likeStatus + ).joinToString(BleConstants.DELIMITER) + + gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) + } + + fun sendSystemState(isDnd: Boolean, isPowerSave: Boolean) { + val payload = listOf( + if (isDnd) "1" else "0", + if (isPowerSave) "1" else "0" + ).joinToString(BleConstants.DELIMITER) + + gattServer?.sendNotification(BleConstants.CHAR_SYSTEM_STATE, payload.toByteArray()) + } + + fun sendClipboard(text: String) { + gattServer?.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, text) + } + + fun sendNotificationDismissal(id: String) { + gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) + } + + fun sendDeviceName() { + val name = android.os.Build.MODEL + gattServer?.sendChunkedNotification(BleConstants.CHAR_DEVICE_NAME, name) + } + + // --- Inbound (Mac -> Android) --- + + fun handleMediaControl(action: String, context: android.content.Context) { + Log.d(TAG, "Media control from BLE: $action") + when (action) { + "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) + "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) + "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) + } + } + + fun handleNotificationAction(data: String, context: android.content.Context) { + Log.d(TAG, "Notification action from BLE: $data") + val parts = data.split(BleConstants.DELIMITER) + if (parts.size >= 2) { + val id = parts[0] + val actionName = parts[1] + com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction(id, actionName) + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index 0fd57735..78538e83 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt @@ -98,6 +98,9 @@ class DataStoreManager(private val context: Context) { private val REMOTE_FLIPPED = booleanPreferencesKey("remote_flipped") + private val BLE_SYNC_ENABLED = booleanPreferencesKey("ble_sync_enabled") + private val BLE_AUTO_CONNECT_ENABLED = booleanPreferencesKey("ble_auto_connect_enabled") + private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -1010,4 +1013,16 @@ class DataStoreManager(private val context: Context) { prefs[ESSENTIALS_CONNECTION_ENABLED] ?: false } } + + suspend fun setBleSyncEnabled(enabled: Boolean) { + context.dataStore.edit { it[BLE_SYNC_ENABLED] = enabled } + } + + fun getBleSyncEnabled(): Flow = context.dataStore.data.map { it[BLE_SYNC_ENABLED] ?: false } + + suspend fun setBleAutoConnectEnabled(enabled: Boolean) { + context.dataStore.edit { it[BLE_AUTO_CONNECT_ENABLED] = enabled } + } + + fun getBleAutoConnectEnabled(): Flow = context.dataStore.data.map { it[BLE_AUTO_CONNECT_ENABLED] ?: true } } diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt index 43bb6dfb..312f704c 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt @@ -1,6 +1,7 @@ package com.sameerasw.airsync.domain.model data class MacDeviceStatus( + val name: String = "Unknown", val battery: MacBattery, val isPaired: Boolean, val music: MacMusicInfo diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index 79c93973..cb64daeb 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt @@ -49,5 +49,6 @@ data class UiState( val isSentryReportingEnabled: Boolean = true, val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, - val isQuickShareEnabled: Boolean = false + val isQuickShareEnabled: Boolean = false, + val bleConnectionState: com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState = com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.DISCONNECTED ) \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt index 97d0940c..27c199b9 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt @@ -51,6 +51,10 @@ class PermissionsActivity : ComponentActivity() { ActivityResultContracts.RequestPermission() ) { refreshUI() } + private val bluetoothPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { refreshUI() } + private var refreshCounter by mutableStateOf(0) @OptIn(ExperimentalMaterial3Api::class) @@ -120,6 +124,9 @@ class PermissionsActivity : ComponentActivity() { onRequestPhonePermission = { requestPhonePermission() }, + onRequestBluetoothPermission = { + requestBluetoothPermission() + }, refreshTrigger = refreshCounter ) } @@ -156,6 +163,20 @@ class PermissionsActivity : ComponentActivity() { phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) } } + + private fun requestBluetoothPermission() { + if (!PermissionUtil.isBluetoothPermissionsGranted(this)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissionLauncher.launch( + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + ) + ) + } + } + } override fun onResume() { super.onResume() diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 993a4add..18d675f9 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState +import com.sameerasw.airsync.presentation.ui.components.cards.BleSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard import com.sameerasw.airsync.presentation.ui.components.cards.DefaultTabCard import com.sameerasw.airsync.presentation.ui.components.cards.DeveloperModeCard @@ -269,6 +270,8 @@ fun SettingsView( title = "Quick Share", subtitle = "Allow receiving files from nearby devices" ) + + BleSyncCard(viewModel = viewModel) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt new file mode 100644 index 00000000..11e71931 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt @@ -0,0 +1,60 @@ +package com.sameerasw.airsync.presentation.ui.components.cards + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.data.ble.BleGattServer +import kotlinx.coroutines.launch + +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import com.sameerasw.airsync.R + +@Composable +fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel) { + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val scope = rememberCoroutineScope() + + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) + + val uiState by viewModel.uiState.collectAsState() + val bleState = uiState.bleConnectionState + + val statusText = when (bleState) { + BleGattServer.BleConnectionState.DISCONNECTED -> "Secondary transport for notifications" + BleGattServer.BleConnectionState.ADVERTISING -> "Advertising (Waiting for Mac...)" + BleGattServer.BleConnectionState.CONNECTED -> "Connected (Authenticating...)" + BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected & Authenticated" + } + + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = "Bluetooth LE Sync", + description = statusText, + isChecked = bleEnabled, + onCheckedChange = { + scope.launch { dataStoreManager.setBleSyncEnabled(it) } + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_searching_24, + title = "BLE Auto-connect", + description = "Automatically switch to Bluetooth when Wi-Fi drops", + isChecked = autoConnect, + onCheckedChange = { + scope.launch { dataStoreManager.setBleAutoConnectEnabled(it) } + }, + enabled = bleEnabled + ) + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt index cd7ff320..180a1ca4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt @@ -35,7 +35,8 @@ enum class PermissionType { WALLPAPER_ACCESS, CALL_LOG, CONTACTS, - PHONE + PHONE, + BLUETOOTH } data class PermissionInfo( @@ -211,5 +212,13 @@ private fun getPermissionInfo(permissionType: PermissionType): PermissionInfo { whyNeeded = "This permission allows AirSync to detect when your phone is ringing, when you answer, or when a call ends, so it can display a live call status on your Mac. \n\nAirSync NEVER accesses your call audio or records conversations. This is used solely to facilitate the remote call notification feature as a device companion.", buttonText = "Grant Phone Access" ) + + PermissionType.BLUETOOTH -> PermissionInfo( + title = "Bluetooth Access", + icon = R.drawable.rounded_sync_desktop_24, + description = "AirSync uses Bluetooth Low Energy (BLE) as a secondary transport to sync notifications and media controls with your Mac when Wi-Fi is unavailable.", + whyNeeded = "To discover and connect to your Mac via Bluetooth, Android requires Bluetooth permissions (Scan, Connect, and Advertise). \n\nThis enables a low-power background connection that keeps your devices synced even when they aren't on the same Wi-Fi network. AirSync only uses Bluetooth to communicate with your authorized Mac devices.", + buttonText = "Grant Bluetooth Access" + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt index 1a04029f..7ee62dc6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt @@ -43,6 +43,7 @@ fun PermissionsScreen( onRequestCallLogPermission: (() -> Unit)? = null, onRequestContactsPermission: (() -> Unit)? = null, onRequestPhonePermission: (() -> Unit)? = null, + onRequestBluetoothPermission: (() -> Unit)? = null, refreshTrigger: Int = 0 ) { val context = LocalContext.current @@ -219,6 +220,15 @@ fun PermissionsScreen( isCritical = false ) } + + "Bluetooth Access" -> { + PermissionButton( + permissionName = permission, + description = "Enables background BLE sync", + onExplainClick = { showDialog = PermissionType.BLUETOOTH }, + isCritical = false + ) + } } } } @@ -263,6 +273,10 @@ fun PermissionsScreen( PermissionType.PHONE -> { onRequestPhonePermission?.invoke() } + + PermissionType.BLUETOOTH -> { + onRequestBluetoothPermission?.invoke() + } } } ) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index bd8ba0fe..ae16cb96 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt @@ -94,24 +94,27 @@ class AirSyncViewModel( } // Connection status listener for WebSocket updates - private val connectionStatusListener: (Boolean) -> Unit = { isConnected -> + private val connectionStatusListener: (Boolean) -> Unit = { isWsConnected -> viewModelScope.launch { + val isBleConnected = _uiState.value.bleConnectionState == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isGlobalConnected = isWsConnected || isBleConnected + _uiState.value = _uiState.value.copy( - isConnected = isConnected, + isConnected = isGlobalConnected, isConnecting = false, - response = if (isConnected) "Connected successfully!" else "Disconnected", - activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null, - macDeviceStatus = if (isConnected) _uiState.value.macDeviceStatus else null + response = if (isGlobalConnected) "Connected successfully!" else "Disconnected", + activeIp = if (isWsConnected) WebSocketUtil.currentIpAddress else null, + macDeviceStatus = if (isGlobalConnected) _uiState.value.macDeviceStatus else null ) - if (isConnected) { + if (isGlobalConnected) { repository.setFirstMacConnectionTime(System.currentTimeMillis()) updateRatingPromptDisplay() } // Update dynamic shortcuts appContext?.let { ctx -> - ShortcutUtil.refreshShortcuts(ctx, isConnected) + ShortcutUtil.refreshShortcuts(ctx, isGlobalConnected) } // Notify Smartspacer of connection status change @@ -180,6 +183,28 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isQuickShareEnabled = enabled) } } + + // Observe BLE connection status + viewModelScope.launch { + com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.connectionState?.collect { state -> + Log.d("AirSyncViewModel", "BLE connection state changed: $state") + val isBleAuthenticated = state == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isWsConnected = WebSocketUtil.isConnected() + + _uiState.value = _uiState.value.copy( + bleConnectionState = state, + isConnected = isWsConnected || isBleAuthenticated + ) + + if (isBleAuthenticated && !isWsConnected) { + // Refresh shortcuts and other side effects if this is the only connection + appContext?.let { ctx -> + ShortcutUtil.refreshShortcuts(ctx, true) + } + updateRatingPromptDisplay() + } + } + } } override fun onCleared() { diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt index e7fe404d..f8fa40e1 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -202,6 +202,11 @@ class AirSyncTileService : TileService() { "Connected" } } ?: "Connected" + } else if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && lastDevice != null) { + // BLE Connected state + state = Tile.STATE_ACTIVE + label = lastDevice.name + subtitle = "Connected BT" } else if (isAuto) { // Auto-reconnect in progress or waiting state = if (isConnecting) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt index 2f3a3520..5f91fdbe 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt @@ -107,7 +107,19 @@ class MacMediaPlayerService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { + val action = intent?.action + Log.d(TAG, "onStartCommand: action=$action") + + if (action == ACTION_STOP_MAC_MEDIA || action == null) { + if (mediaSession == null) { + val notification = createMediaNotification("", "", false) + startForeground(NOTIFICATION_ID, notification) + } + stopMacMediaSession() + return START_NOT_STICKY + } + + when (action) { ACTION_START_MAC_MEDIA -> { val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val artist = intent.getStringExtra(EXTRA_ARTIST) ?: "" @@ -131,10 +143,6 @@ class MacMediaPlayerService : Service() { updateMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) } - - ACTION_STOP_MAC_MEDIA -> { - stopMacMediaSession() - } // Handle media control actions from notification buttons "MAC_MEDIA_play" -> { sendMacMediaControl("play") diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 0ae06ea9..76f1a7f9 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -499,41 +499,38 @@ class MediaNotificationListener : NotificationListenerService() { ) } - // Send dismissal update to Mac for real removals + // Sync dismissal to Mac serviceScope.launch { try { - val id = NotificationDismissalUtil.getIdForSbn(sbn) - if (id.isNullOrEmpty()) { - // Skip if we cannot map to a known id - return@launch - } + val id = NotificationDismissalUtil.getIdForSbn(sbn) ?: sbn.key + // If this dismissal was initiated by our own dismiss request, skip echo val wasSuppressed = NotificationDismissalUtil.consumeSuppressed(id) if (wasSuppressed) { - // Clean local caches and ignore + Log.d(TAG, "Dismissal for $id was suppressed (originated from Mac)") NotificationDismissalUtil.removeFromCaches(id) return@launch } - // Build and send update - if (WebSocketUtil.isConnected()) { - val update = JsonUtil.toSingleLine( - JsonUtil.createNotificationUpdateJson( - id, - dismissed = true, - action = "dismiss" - ) + // Build and send update via WebSocket + val updateJson = JsonUtil.toSingleLine( + JsonUtil.createNotificationUpdateJson( + id, + dismissed = true, + action = "dismiss" ) - val sent = WebSocketUtil.sendMessage(update) - Log.d(TAG, "Sent notificationUpdate for $id: $sent") - } else { - Log.d(TAG, "WebSocket not connected; skipping notificationUpdate for $id") - } + ) + WebSocketUtil.sendMessage(updateJson) + + // Also sync via BLE + com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotificationDismissal(id) + + Log.d(TAG, "Sent notification removal sync for $id (WS and BLE)") // Remove from caches since it's gone now NotificationDismissalUtil.removeFromCaches(id) } catch (e: Exception) { - Log.e(TAG, "Error sending notificationUpdate: ${e.message}") + Log.e(TAG, "Error syncing notification removal: ${e.message}") } } } @@ -667,8 +664,15 @@ class MediaNotificationListener : NotificationListenerService() { Log.e(TAG, "Failed to send notification via WebSocket") } } else { - Log.d(TAG, "WebSocket not connected, skipping notification sync") + Log.d(TAG, "WebSocket not connected, skipping notification sync via WS") } + + // Always attempt BLE sync if any device is connected + com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotification( + pkg = sbn.packageName, + title = title, + text = body + ) } else { Log.d(TAG, "Skipping empty notification from ${sbn.packageName}") } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index e6536f1c..8817fc4b 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -61,6 +61,7 @@ object JsonUtil { version: String, wallpaperBase64: String?, adbPorts: List, + bleAuthToken: String? = null, targetIpAddress: String? = null ): String { val wallpaperJson = if (wallpaperBase64 != null) { @@ -68,10 +69,15 @@ object JsonUtil { } else { "" } + val bleTokenJson = if (bleAuthToken != null) { + ""","bleAuthToken":"$bleAuthToken"""" + } else { + "" + } val portsJson = adbPorts.joinToString(",") { "\"$it\"" } val targetIpJson = if (targetIpAddress != null) """, "targetIpAddress": "$targetIpAddress" """ else "" - return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$wallpaperJson$targetIpJson}}""" + return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$wallpaperJson$bleTokenJson$targetIpJson}}""" } /** diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index 1804b4f2..c2120cbe 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -6,6 +6,7 @@ import android.graphics.BitmapFactory import android.util.Base64 import android.util.Log import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.data.ble.BleGattServer import com.sameerasw.airsync.domain.model.MacBattery import com.sameerasw.airsync.domain.model.MacDeviceStatus import com.sameerasw.airsync.domain.model.MacMusicInfo @@ -27,8 +28,83 @@ object MacDeviceStatusManager { private val _albumArt = MutableStateFlow(null) val albumArt: StateFlow = _albumArt.asStateFlow() + fun updateBatteryStatus(context: Context, level: Int, isCharging: Boolean) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = current?.name ?: "Unknown", + batteryLevel = level, + isCharging = isCharging, + isPaired = current?.isPaired ?: true, + isPlaying = current?.music?.isPlaying ?: false, + title = current?.music?.title ?: "", + artist = current?.music?.artist ?: "", + volume = current?.music?.volume ?: 0, + isMuted = current?.music?.isMuted ?: false, + albumArt = null, // keep current + likeStatus = current?.music?.likeStatus ?: "none", + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + + fun updateMacStatus(context: Context, name: String) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = name, + batteryLevel = current?.battery?.level ?: -1, + isCharging = current?.battery?.isCharging ?: false, + isPaired = current?.isPaired ?: true, + isPlaying = current?.music?.isPlaying ?: false, + title = current?.music?.title ?: "", + artist = current?.music?.artist ?: "", + volume = current?.music?.volume ?: 0, + isMuted = current?.music?.isMuted ?: false, + albumArt = null, // keep current + likeStatus = current?.music?.likeStatus ?: "none", + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + + fun updateMusicStatus( + context: Context, + isPlaying: Boolean, + title: String, + artist: String, + volume: Int, + isMuted: Boolean, + likeStatus: String + ) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = current?.name ?: "Unknown", + batteryLevel = current?.battery?.level ?: -1, + isCharging = current?.battery?.isCharging ?: false, + isPaired = current?.isPaired ?: true, + isPlaying = isPlaying, + title = title, + artist = artist, + volume = volume, + isMuted = isMuted, + albumArt = null, // keep current + likeStatus = likeStatus, + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + fun updateStatus( context: Context, + name: String, batteryLevel: Int, isCharging: Boolean, isPaired: Boolean, @@ -63,6 +139,7 @@ object MacDeviceStatusManager { ) val status = MacDeviceStatus( + name = name, battery = macBattery, isPaired = isPaired, music = macMusicInfo @@ -81,7 +158,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { val ds = DataStoreManager(context) val isMediaControlsEnabled = ds.getMacMediaControlsEnabled().first() - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnected() || WebSocketUtil.isConnecting() || BleGattServer.isAnyAuthenticated() val isEssentialsEnabled = ds.getEssentialsConnectionEnabled().first() if (isConnected && isMediaControlsEnabled && (title.isNotEmpty() || artist.isNotEmpty() || isPlaying)) { @@ -193,7 +270,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { try { // Check current state - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnected() || BleGattServer.isAnyAuthenticated() val currentStatus = _macDeviceStatus.value if (isConnected && currentStatus != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt index ecd8b1f1..16a53e45 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -187,6 +187,10 @@ object PermissionUtil { missing.add("Phone Access") } + if (!isBluetoothPermissionsGranted(context)) { + missing.add("Bluetooth Access") + } + return missing } @@ -240,6 +244,10 @@ object PermissionUtil { optional.add("Phone Access") } + if (!isBluetoothPermissionsGranted(context)) { + optional.add("Bluetooth Access") + } + return optional } @@ -272,4 +280,17 @@ object PermissionUtil { Manifest.permission.READ_PHONE_STATE ) == PackageManager.PERMISSION_GRANTED } + + /** + * Check if Bluetooth permissions are granted (Connect and Advertise/Scan on Android 12+) + */ + fun isBluetoothPermissionsGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED + } else { + // On older versions, manifest permissions are enough + true + } + } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 3da42518..e3adde92 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -110,17 +110,32 @@ object SyncManager { shouldSync = true // First time } - if (shouldSync && WebSocketUtil.isConnected()) { - val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) - val success = WebSocketUtil.sendMessage(statusJson) + if (shouldSync) { + if (WebSocketUtil.isConnected()) { + val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) + val success = WebSocketUtil.sendMessage(statusJson) + + if (success) { + // Log.d(TAG, "Device status synced successfully") + lastAudioInfo = currentAudio + lastBatteryInfo = currentBattery + lastVolume = currentAudio.volume + } else { + Log.w(TAG, "Failed to sync device status") + } + } - if (success) { - // Log.d(TAG, "Device status synced successfully") + // Always sync via BLE if possible + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + + // Also update cache if BLE is used, to avoid redundant BLE sends + // (Actually we already updated it above if WS succeeded, + // but if WS failed and BLE succeeded, we should still update it) + if (!WebSocketUtil.isConnected()) { lastAudioInfo = currentAudio lastBatteryInfo = currentBattery lastVolume = currentAudio.volume - } else { - Log.w(TAG, "Failed to sync device status") } } @@ -189,17 +204,22 @@ object SyncManager { Log.d(TAG, "Discovered ADB ports: $adbPorts") val deviceId = DeviceInfoUtil.getDeviceId(context) + val symmetricKey = dataStoreManager.getLastConnectedDevice().first()?.symmetricKey ?: "" + val bleAuthToken = com.sameerasw.airsync.data.ble.BleTransportBridge.deriveAuthToken(symmetricKey) + val liteDeviceInfoJson = JsonUtil.createDeviceInfoJson( - deviceId, - deviceName, - localIp, - port, - version, - adbPorts, - WebSocketUtil.currentIpAddress + id = deviceId, + name = deviceName, + ipAddress = localIp, + port = port, + version = version, + wallpaperBase64 = null, + adbPorts = adbPorts, + bleAuthToken = bleAuthToken, + targetIpAddress = WebSocketUtil.currentIpAddress ) if (WebSocketUtil.sendMessage(liteDeviceInfoJson)) { - Log.d(TAG, "Lite device info sent to trigger macInfo") + Log.d(TAG, "Lite device info sent to trigger macInfo (BLE token included)") } else { Log.e(TAG, "Failed to send lite device info") } @@ -220,14 +240,15 @@ object SyncManager { } val currentAdbPorts = discoveredServices.map { it.port.toString() } val fullDeviceInfoJson = JsonUtil.createDeviceInfoJson( - deviceId, - deviceName, - localIp, - port, - version, - wallpaperBase64, - currentAdbPorts, - WebSocketUtil.currentIpAddress + id = deviceId, + name = deviceName, + ipAddress = localIp, + port = port, + version = version, + wallpaperBase64 = wallpaperBase64, + adbPorts = currentAdbPorts, + bleAuthToken = bleAuthToken, + targetIpAddress = WebSocketUtil.currentIpAddress ) if (WebSocketUtil.sendMessage(fullDeviceInfoJson)) { Log.d( diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index ba0f46a6..cfaf25d9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -468,6 +468,7 @@ object WebSocketMessageHandler { // Update the Mac device status manager with all media info MacDeviceStatusManager.updateStatus( context = context, + name = data.optString("name", MacDeviceStatusManager.macDeviceStatus.value?.name ?: "Unknown"), batteryLevel = batteryLevel, isCharging = isCharging, isPaired = isPaired, diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index ba6e47ae..03295d5d 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -2,18 +2,22 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log +import com.sameerasw.airsync.data.ble.BleConstants import com.sameerasw.airsync.widget.AirSyncWidgetProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import org.json.JSONObject import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -32,6 +36,11 @@ object WebSocketUtil { private val isConnected = AtomicBoolean(false) private val isConnecting = AtomicBoolean(false) + private fun updateConnectedStatus(status: Boolean) { + isConnected.set(status) + _connectionStateFlow.value = status + } + // Transport state: true after OkHttp onOpen, false after closing/failure/disconnect private val isSocketOpen = AtomicBoolean(false) private val handshakeCompleted = AtomicBoolean(false) @@ -57,6 +66,9 @@ object WebSocketUtil { // Global connection status listeners for UI updates private val connectionStatusListeners = mutableSetOf<(Boolean) -> Unit>() + private val _connectionStateFlow = MutableStateFlow(false) + val connectionState = _connectionStateFlow.asStateFlow() + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -229,7 +241,7 @@ object WebSocketUtil { WebSocketUtil.webSocket = webSocket currentIpAddress = ip // Store the successful IP isSocketOpen.set(true) - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(true) try { @@ -243,7 +255,7 @@ object WebSocketUtil { delay(HANDSHAKE_TIMEOUT_MS) if (!handshakeCompleted.get()) { Log.w(TAG, "Handshake timed out") - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) try { webSocket.close(4001, "Handshake timeout") @@ -293,7 +305,7 @@ object WebSocketUtil { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { } - isConnected.set(true) + updateConnectedStatus(true) isConnecting.set(false) handshakeTimeoutJob?.cancel() try { @@ -364,7 +376,7 @@ object WebSocketUtil { } } } - isConnected.set(false) + updateConnectedStatus(false) isSocketOpen.set(false) isConnecting.set(false) handshakeCompleted.set(false) @@ -419,7 +431,7 @@ object WebSocketUtil { } } } - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) @@ -511,17 +523,90 @@ object WebSocketUtil { */ fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) - return if (isSocketOpen.get() && webSocket != null) { - Log.d(TAG, "Sending message: $message") + if (isSocketOpen.get() && webSocket != null) { + Log.d(TAG, "Sending message via WebSocket: $message") val messageToSend = currentSymmetricKey?.let { key -> CryptoUtil.encryptMessage(message, key) } ?: message - webSocket!!.send(messageToSend) + return webSocket!!.send(messageToSend) } else { - Log.w(TAG, "WebSocket not connected, cannot send message") - false + // Fallback to BLE if authenticated + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() + if (ble != null && ble.isAuthenticated) { + Log.d(TAG, "WebSocket not connected, falling back to BLE: $message") + return sendOverBLE(message) + } + + Log.w(TAG, "Neither WebSocket nor BLE connected, cannot send message") + return false + } + } + + private fun sendOverBLE(message: String): Boolean { + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() ?: return false + try { + val json = JSONObject(message) + val type = json.optString("type") + val data = json.optJSONObject("data") ?: JSONObject() + + when (type) { + "notificationAction" -> { + val pkg = data.optString("package") + val actionId = data.optString("actionId") + val payload = "$pkg|$actionId" + ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_ACTION, payload) + return true + } + "mediaControl" -> { + val action = data.optString("action") + // Protocol: type|action + val payload = "media|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "volumeControl" -> { + val action = data.optString("action") + // Protocol: type|action + val payload = "volume|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "clipboard", "clipboardUpdate" -> { + val content = data.optString("text", data.optString("content")) + ble.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, content) + return true + } + "dismissNotification" -> { + val id = data.optString("id") + ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) + return true + } + "remoteControl" -> { + val action = data.optString("action") + // Filter out high-frequency cursor controls over BLE + if (action == "mouse_move" || action == "mouse_click" || action == "mouse_scroll") { + return false + } + // Include value if present (e.g. vol_set needs level) + val value = if (data.has("value")) data.opt("value")?.toString() else null + val payload = if (value != null) "remote|$action|$value" else "remote|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "status" -> { + val battery = data.optJSONObject("battery") + if (battery != null) { + val level = battery.optInt("level") + ble.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + return true + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error sending over BLE fallback: ${e.message}") } + return false } /** @@ -530,7 +615,7 @@ object WebSocketUtil { */ fun disconnect(context: Context? = null) { Log.d(TAG, "Disconnecting WebSocket") - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) @@ -612,7 +697,7 @@ object WebSocketUtil { } fun isConnected(): Boolean { - return isConnected.get() + return isConnected.get() || com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() } fun isConnecting(): Boolean { diff --git a/app/src/main/res/drawable/rounded_bluetooth_24.xml b/app/src/main/res/drawable/rounded_bluetooth_24.xml new file mode 100644 index 00000000..9600872a --- /dev/null +++ b/app/src/main/res/drawable/rounded_bluetooth_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml new file mode 100644 index 00000000..341352de --- /dev/null +++ b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml @@ -0,0 +1,20 @@ + + + + + + From ee301f4fc4254029e278aeb8d935701e4ffa630b Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 01:16:59 +0530 Subject: [PATCH 2/9] feat: update BLE status labels and move BleSyncCard to a new Bluetooth settings section --- .../airsync/presentation/ui/components/SettingsView.kt | 7 +++++++ .../presentation/ui/components/cards/BleSyncCard.kt | 10 ++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 18d675f9..c6389d75 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -270,7 +270,14 @@ fun SettingsView( title = "Quick Share", subtitle = "Allow receiving files from nearby devices" ) + } + } + + // Integration Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsCategoryTitle("Bluetooth") + RoundedCardContainer { BleSyncCard(viewModel = viewModel) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt index 11e71931..045fb0f1 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt @@ -29,13 +29,12 @@ fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncV val bleState = uiState.bleConnectionState val statusText = when (bleState) { - BleGattServer.BleConnectionState.DISCONNECTED -> "Secondary transport for notifications" - BleGattServer.BleConnectionState.ADVERTISING -> "Advertising (Waiting for Mac...)" - BleGattServer.BleConnectionState.CONNECTED -> "Connected (Authenticating...)" - BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected & Authenticated" + BleGattServer.BleConnectionState.DISCONNECTED -> "For nearby connection" + BleGattServer.BleConnectionState.ADVERTISING -> "Scanning" + BleGattServer.BleConnectionState.CONNECTED -> "Authenticating" + BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected" } - RoundedCardContainer { IconToggleItem( iconRes = R.drawable.rounded_bluetooth_24, title = "Bluetooth LE Sync", @@ -56,5 +55,4 @@ fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncV }, enabled = bleEnabled ) - } } From b36eb79458ae5192ee15f9809b83990fb1a94c4f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 16:24:34 +0530 Subject: [PATCH 3/9] chore: ignore .agents directory and update BLE heartbeat interval and logic in SyncManager --- .gitignore | 1 + .../sameerasw/airsync/utils/SyncManager.kt | 23 ++++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 8d589cd4..9939654b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ app/release local.properties .vscode/launch.json build/reports/problems/problems-report.html +.agents/ \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index e3adde92..897d765c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean object SyncManager { private const val TAG = "SyncManager" - private const val BATTERY_SYNC_INTERVAL = 20_000L // 20 seconds + private const val BATTERY_SYNC_INTERVAL = 10_000L // 10 seconds private const val MAX_DAILY_ICON_SYNCS = 3 private var syncJob: Job? = null @@ -124,19 +124,16 @@ object SyncManager { Log.w(TAG, "Failed to sync device status") } } + } - // Always sync via BLE if possible - com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) - - // Also update cache if BLE is used, to avoid redundant BLE sends - // (Actually we already updated it above if WS succeeded, - // but if WS failed and BLE succeeded, we should still update it) - if (!WebSocketUtil.isConnected()) { - lastAudioInfo = currentAudio - lastBatteryInfo = currentBattery - lastVolume = currentAudio.volume - } + // Heartbeat: Always sync via BLE if possible (even if no change, to reset Mac watchdog) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + + if (shouldSync && !WebSocketUtil.isConnected()) { + lastAudioInfo = currentAudio + lastBatteryInfo = currentBattery + lastVolume = currentAudio.volume } } catch (e: Exception) { From 4f1875cd34c2f3da8c4448a86cf06586e9bf1203 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 16:30:57 +0530 Subject: [PATCH 4/9] feat: send battery heartbeat over BLE and optimize media state updates in SyncManager --- .../airsync/data/ble/BleGattServer.kt | 22 +++++++++++++++++++ .../sameerasw/airsync/utils/SyncManager.kt | 13 ++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 7e1330dc..07f61d7d 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -47,6 +47,7 @@ class BleGattServer(private val context: Context) { var isAuthenticated = false private set private var negotiatedMtu = 23 + private var heartbeatJob: Job? = null enum class BleConnectionState { DISCONNECTED, ADVERTISING, CONNECTED, AUTHENTICATED @@ -81,6 +82,7 @@ class BleGattServer(private val context: Context) { */ fun stop() { stopAdvertising() + stopHeartbeat() gattServer?.clearServices() gattServer?.close() gattServer = null @@ -214,6 +216,7 @@ class BleGattServer(private val context: Context) { Log.d(TAG, "Device disconnected: ${device.address}") connectedDevices.remove(device) if (connectedDevices.isEmpty()) { + stopHeartbeat() _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false } @@ -319,6 +322,7 @@ class BleGattServer(private val context: Context) { _connectionState.value = BleConnectionState.AUTHENTICATED sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS)) BleTransportBridge.sendDeviceName() + startHeartbeat() } else { Log.w(TAG, "BLE Auth Failed! Token mismatch.") sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) @@ -330,6 +334,24 @@ class BleGattServer(private val context: Context) { } } + private fun startHeartbeat() { + stopHeartbeat() + heartbeatJob = scope.launch { + while (isActive && isAuthenticated) { + delay(5000) + if (connectedDevices.isNotEmpty()) { + val level = com.sameerasw.airsync.utils.DeviceInfoUtil.getBatteryInfo(context).level + sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + } + } + } + } + + private fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + private fun handleNotificationDismiss(id: String) { Log.d(TAG, "Handling notification dismissal from BLE: $id") NotificationDismissalUtil.dismissNotification(id) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 897d765c..40e0597f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -42,6 +42,12 @@ object SyncManager { while (isActive && isSyncing.get()) { try { + // Heartbeat: Always sync battery over BLE if authenticated to keep Mac connection alive + if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { + val currentBattery = DeviceInfoUtil.getBatteryInfo(context) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + } + // Check if WebSocket is connected and sync is enabled if (WebSocketUtil.isConnected()) { val dataStoreManager = DataStoreManager(context) @@ -126,9 +132,10 @@ object SyncManager { } } - // Heartbeat: Always sync via BLE if possible (even if no change, to reset Mac watchdog) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + // Media state still needs to be sent if it changed + if (shouldSync) { + com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + } if (shouldSync && !WebSocketUtil.isConnected()) { lastAudioInfo = currentAudio From edc52d0ea162fbaee22b127ddf8e5668c63fb191 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 17:06:47 +0530 Subject: [PATCH 5/9] refactor: centralize BLE transport logic into WebSocketUtil and use constants for parsing --- .../airsync/data/ble/BleGattServer.kt | 4 +- .../service/MediaNotificationListener.kt | 32 ++++----------- .../sameerasw/airsync/utils/SyncManager.kt | 41 +++++++------------ .../sameerasw/airsync/utils/WebSocketUtil.kt | 23 ++++++++++- 4 files changed, 47 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 07f61d7d..9925659b 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -360,7 +360,7 @@ class BleGattServer(private val context: Context) { private fun handleMacBattery(value: ByteArray) { if (!isAuthenticated) return val payload = String(value, Charsets.UTF_8) - val parts = payload.split("|") + val parts = payload.split(BleConstants.DELIMITER) if (parts.size >= 2) { val level = parts[0].toIntOrNull() ?: -1 val isCharging = parts[1] == "1" @@ -375,7 +375,7 @@ class BleGattServer(private val context: Context) { private fun handleMacMediaState(payload: String) { if (!isAuthenticated) return - val parts = payload.split("|") + val parts = payload.split(BleConstants.DELIMITER) if (parts.size >= 6) { val isPlaying = parts[0] == "1" val title = parts[1] diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 76f1a7f9..8f3be0cc 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -522,10 +522,7 @@ class MediaNotificationListener : NotificationListenerService() { ) WebSocketUtil.sendMessage(updateJson) - // Also sync via BLE - com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotificationDismissal(id) - - Log.d(TAG, "Sent notification removal sync for $id (WS and BLE)") + Log.d(TAG, "Sent notification removal sync for $id") // Remove from caches since it's gone now NotificationDismissalUtil.removeFromCaches(id) @@ -652,27 +649,16 @@ class MediaNotificationListener : NotificationListenerService() { Log.d(TAG, "Preparing to send notification: $notificationJson") - if (WebSocketUtil.isConnected()) { - Log.d(TAG, "Sending notification via WebSocket") - val success = WebSocketUtil.sendMessage(notificationJson) - if (success) { - Log.d( - TAG, - "Notification sent successfully via existing WebSocket connection" - ) - } else { - Log.e(TAG, "Failed to send notification via WebSocket") - } + Log.d(TAG, "Sending notification via WS/BLE") + val success = WebSocketUtil.sendMessage(notificationJson) + if (success) { + Log.d( + TAG, + "Notification sent successfully" + ) } else { - Log.d(TAG, "WebSocket not connected, skipping notification sync via WS") + Log.e(TAG, "Failed to send notification") } - - // Always attempt BLE sync if any device is connected - com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotification( - pkg = sbn.packageName, - title = title, - text = body - ) } else { Log.d(TAG, "Skipping empty notification from ${sbn.packageName}") } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 40e0597f..3e3b7c8c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -42,14 +42,14 @@ object SyncManager { while (isActive && isSyncing.get()) { try { - // Heartbeat: Always sync battery over BLE if authenticated to keep Mac connection alive - if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { + // Heartbeat: Sync battery over BLE if authenticated and WS not connected to keep Mac connection alive + if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && !WebSocketUtil.isConnected()) { val currentBattery = DeviceInfoUtil.getBatteryInfo(context) com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) } - // Check if WebSocket is connected and sync is enabled - if (WebSocketUtil.isConnected()) { + // Check if sync is needed (either via WebSocket or BLE) + if (WebSocketUtil.isConnected() || com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { val dataStoreManager = DataStoreManager(context) val isSyncEnabled = dataStoreManager.getNotificationSyncEnabled().first() @@ -117,32 +117,19 @@ object SyncManager { } if (shouldSync) { - if (WebSocketUtil.isConnected()) { - val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) - val success = WebSocketUtil.sendMessage(statusJson) - - if (success) { - // Log.d(TAG, "Device status synced successfully") - lastAudioInfo = currentAudio - lastBatteryInfo = currentBattery - lastVolume = currentAudio.volume - } else { - Log.w(TAG, "Failed to sync device status") - } + val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) + // sendMessage handles both WebSocket and BLE fallback internally + val success = WebSocketUtil.sendMessage(statusJson) + + if (success) { + lastAudioInfo = currentAudio + lastBatteryInfo = currentBattery + lastVolume = currentAudio.volume + } else { + Log.w(TAG, "Failed to sync device status (WS/BLE)") } } - // Media state still needs to be sent if it changed - if (shouldSync) { - com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) - } - - if (shouldSync && !WebSocketUtil.isConnected()) { - lastAudioInfo = currentAudio - lastBatteryInfo = currentBattery - lastVolume = currentAudio.volume - } - } catch (e: Exception) { Log.e(TAG, "Error checking device status: ${e.message}") } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index 03295d5d..36b28a53 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -3,6 +3,8 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log import com.sameerasw.airsync.data.ble.BleConstants +import com.sameerasw.airsync.data.ble.BleTransportBridge +import com.sameerasw.airsync.domain.model.AudioInfo import com.sameerasw.airsync.widget.AirSyncWidgetProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -594,13 +596,32 @@ object WebSocketUtil { ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) return true } + "notification" -> { + val pkg = data.optString("package") + val title = data.optString("title") + val body = data.optString("body") + BleTransportBridge.sendNotification(pkg, title, body) + return true + } "status" -> { val battery = data.optJSONObject("battery") if (battery != null) { val level = battery.optInt("level") ble.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) - return true } + val music = data.optJSONObject("music") + if (music != null) { + val audio = AudioInfo( + isPlaying = music.optBoolean("isPlaying"), + title = music.optString("title"), + artist = music.optString("artist"), + volume = music.optInt("volume"), + isMuted = music.optBoolean("isMuted"), + likeStatus = music.optString("likeStatus") + ) + BleTransportBridge.sendMediaState(audio) + } + return true } } } catch (e: Exception) { From b5c86e2031ff3daf00fc1ebd93ba01fef41cb1e4 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 17:12:25 +0530 Subject: [PATCH 6/9] feat: include app name parameter in notification payload transmission --- .../java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt | 4 ++-- .../main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index f3c0c847..a8003a06 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -28,8 +28,8 @@ object BleTransportBridge { // --- Outbound (Android -> Mac) --- - fun sendNotification(pkg: String, title: String, text: String) { - val payload = listOf(pkg, title, text).joinToString(BleConstants.DELIMITER) + fun sendNotification(pkg: String, appName: String, title: String, text: String) { + val payload = listOf(pkg, appName, title, text).joinToString(BleConstants.DELIMITER) gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DATA, payload) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index 36b28a53..78715773 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -598,9 +598,10 @@ object WebSocketUtil { } "notification" -> { val pkg = data.optString("package") + val appName = data.optString("app") val title = data.optString("title") val body = data.optString("body") - BleTransportBridge.sendNotification(pkg, title, body) + BleTransportBridge.sendNotification(pkg, appName, title, body) return true } "status" -> { From 832f56ee722c4fe9c2da0169d00d7254447849bd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 17:42:36 +0530 Subject: [PATCH 7/9] feat: add albumArtLite support for optimized BLE media state transmission --- .../com/sameerasw/airsync/data/ble/BleGattServer.kt | 3 ++- .../airsync/data/ble/BleTransportBridge.kt | 3 ++- .../sameerasw/airsync/domain/model/DeviceStatus.kt | 2 ++ .../airsync/service/MediaNotificationListener.kt | 13 +++++++++++++ .../com/sameerasw/airsync/utils/DeviceInfoUtil.kt | 3 +++ .../java/com/sameerasw/airsync/utils/JsonUtil.kt | 4 +++- .../airsync/utils/MacDeviceStatusManager.kt | 5 +++-- .../com/sameerasw/airsync/utils/WebSocketUtil.kt | 3 ++- 8 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 9925659b..d4727d14 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -383,10 +383,11 @@ class BleGattServer(private val context: Context) { val volume = parts[3].toIntOrNull() ?: 0 val isMuted = parts[4] == "1" val likeStatus = parts[5] + val albumArt = if (parts.size >= 7) parts[6] else null Log.d(TAG, "Received Mac media state via BLE: $title by $artist (Playing: $isPlaying)") MacDeviceStatusManager.updateMusicStatus( - context, isPlaying, title, artist, volume, isMuted, likeStatus + context, isPlaying, title, artist, volume, isMuted, likeStatus, albumArt ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index a8003a06..72b0ed89 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -45,7 +45,8 @@ object BleTransportBridge { audio.artist, audio.volume.toString(), if (audio.isMuted) "1" else "0", - audio.likeStatus + audio.likeStatus, + audio.albumArtLite ?: "" ).joinToString(BleConstants.DELIMITER) gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt index 22ad0224..6d942489 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt @@ -12,6 +12,7 @@ data class AudioInfo( val volume: Int, val isMuted: Boolean, val albumArt: String? = null, + val albumArtLite: String? = null, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) @@ -21,6 +22,7 @@ data class MediaInfo( val title: String, val artist: String, val albumArt: String? = null, + val albumArtLite: String? = null, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 8f3be0cc..13366288 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -131,6 +131,18 @@ class MediaNotificationListener : NotificationListenerService() { Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } + val albumArtLiteBase64 = albumArtBitmap?.let { + try { + val outputStream = ByteArrayOutputStream() + // Scale down to 80x80 and lower quality for BLE + val scaled = Bitmap.createScaledBitmap(it, 80, 80, true) + scaled.compress(Bitmap.CompressFormat.JPEG, 30, outputStream) + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) + } catch (e: Exception) { + null + } + } + // Log.d(TAG, "Media session - Title: $title, Artist: $artist, Playing: $isPlaying, State: ${playbackState?.state}") @@ -153,6 +165,7 @@ class MediaNotificationListener : NotificationListenerService() { title = title, artist = artist, albumArt = albumArtBase64, + albumArtLite = albumArtLiteBase64, likeStatus = likeStatus ) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt index 8aac4a56..0658b703 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -151,6 +151,7 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = null, + albumArtLite = null, likeStatus = "none" ) } @@ -166,6 +167,7 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = mediaInfo.albumArt, + albumArtLite = mediaInfo.albumArtLite, likeStatus = mediaInfo.likeStatus ) } catch (e: Exception) { @@ -188,6 +190,7 @@ object DeviceInfoUtil { volume = audioInfo.volume, isMuted = audioInfo.isMuted, albumArt = audioInfo.albumArt, + albumArtLite = audioInfo.albumArtLite, likeStatus = audioInfo.likeStatus ) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index 8817fc4b..b8466fb0 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -156,10 +156,12 @@ object JsonUtil { volume: Int, isMuted: Boolean, albumArt: String?, + albumArtLite: String? = null, likeStatus: String ): String { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson,"likeStatus":"$likeStatus"}}}""" + val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" + return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"likeStatus":"$likeStatus"}}}""" } /** diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index c2120cbe..65cacb01 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -79,7 +79,8 @@ object MacDeviceStatusManager { artist: String, volume: Int, isMuted: Boolean, - likeStatus: String + likeStatus: String, + albumArt: String? = null ) { val current = _macDeviceStatus.value updateStatus( @@ -93,7 +94,7 @@ object MacDeviceStatusManager { artist = artist, volume = volume, isMuted = isMuted, - albumArt = null, // keep current + albumArt = albumArt, likeStatus = likeStatus, elapsedTime = current?.music?.elapsedTime ?: 0L, duration = current?.music?.duration ?: 0L, diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index 78715773..e02f4ecf 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -618,7 +618,8 @@ object WebSocketUtil { artist = music.optString("artist"), volume = music.optInt("volume"), isMuted = music.optBoolean("isMuted"), - likeStatus = music.optString("likeStatus") + likeStatus = music.optString("likeStatus"), + albumArtLite = music.optString("albumArtLite") ) BleTransportBridge.sendMediaState(audio) } From e383a94f67be8f0c3f6438305891e03e2698ecde Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 11:24:18 +0530 Subject: [PATCH 8/9] feat: move auto-reconnect toggle to a new dedicated ConnectionSettingsBottomSheet accessed via LastConnectedDeviceCard --- .../ui/components/SettingsView.kt | 9 -- .../cards/LastConnectedDeviceCard.kt | 74 ++++++++++-- .../sheets/ConnectionSettingsBottomSheet.kt | 112 ++++++++++++++++++ app/src/main/res/values/strings.xml | 10 ++ 4 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index c6389d75..eff639d3 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState -import com.sameerasw.airsync.presentation.ui.components.cards.BleSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard import com.sameerasw.airsync.presentation.ui.components.cards.DefaultTabCard import com.sameerasw.airsync.presentation.ui.components.cards.DeveloperModeCard @@ -274,14 +273,6 @@ fun SettingsView( } - // Integration Section - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SettingsCategoryTitle("Bluetooth") - RoundedCardContainer { - BleSyncCard(viewModel = viewModel) - } - } - // Integration Section Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsCategoryTitle("Integration") diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 7f216422..a797a9ac 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -1,12 +1,16 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -15,14 +19,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice +import com.sameerasw.airsync.presentation.ui.components.sheets.ConnectionSettingsBottomSheet import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @@ -119,8 +129,6 @@ fun LastConnectedDeviceCard( // Text("Type: $type", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) // } - - Button( onClick = { HapticUtil.performClick(haptics) @@ -140,22 +148,68 @@ fun LastConnectedDeviceCard( Text("Quick Connect") } - // Auto-reconnect toggle + + } + var showBottomSheet by remember { mutableStateOf(false) } + + // Auto-reconnect & Bluetooth settings card + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + HapticUtil.performClick(haptics) + showBottomSheet = true + }, + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { Row( modifier = Modifier - .fillMaxWidth().padding(top = 8.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Auto reconnect", style = MaterialTheme.typography.bodyMedium) - Switch(checked = isAutoReconnectEnabled, onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_compare_arrows_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary ) - onToggleAutoReconnect(enabled) - }) + Column { + Text( + text = stringResource(R.string.bluetooth_settings_card_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.bluetooth_settings_card_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24), + contentDescription = "Configure settings", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } + if (showBottomSheet) { + ConnectionSettingsBottomSheet( + isAutoReconnectEnabled = isAutoReconnectEnabled, + onToggleAutoReconnect = onToggleAutoReconnect, + onDismissRequest = { showBottomSheet = false } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt new file mode 100644 index 00000000..fc6940fb --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt @@ -0,0 +1,112 @@ +package com.sameerasw.airsync.presentation.ui.components.sheets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionSettingsBottomSheet( + isAutoReconnectEnabled: Boolean, + onToggleAutoReconnect: (Boolean) -> Unit, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val scope = rememberCoroutineScope() + + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.bluetooth_settings_card_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 8.dp) + ) + + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_sync_desktop_24, + title = stringResource(R.string.setting_auto_reconnect_title), + description = stringResource(R.string.setting_auto_reconnect_desc), + isChecked = isAutoReconnectEnabled, + onCheckedChange = { enabled -> + onToggleAutoReconnect(enabled) + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = stringResource(R.string.setting_nearby_connection_title), + description = stringResource(R.string.setting_nearby_connection_desc), + isChecked = bleEnabled, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleSyncEnabled(enabled) + } + } + ) + + AnimatedVisibility( + visible = bleEnabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_searching_24, + title = stringResource(R.string.setting_auto_switch_title), + description = stringResource(R.string.setting_auto_switch_desc), + isChecked = autoConnect, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleAutoConnectEnabled(enabled) + } + } + ) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d752d8c8..f33d583d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,4 +81,14 @@ Auto App icon credits: @Syntrop2k2 on Telegram https://t.me/Syntrop2k2 + + + Reconnect + Configure auto reconnect + Auto re-connect + Attempted when disconnected unexpectedly + Nearby connection + Use Bluetooth LE + Auto switch + Auto switch to BLE if connection lost \ No newline at end of file From 113eac155b9fa21ce5dd88cb253f52ba4526a5e1 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 11:38:07 +0530 Subject: [PATCH 9/9] refactor: Combine BLE settings to one toggle --- .../sheets/ConnectionSettingsBottomSheet.kt | 26 ++----------------- app/src/main/res/values/strings.xml | 6 ++--- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt index fc6940fb..030fb7e4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt @@ -1,10 +1,5 @@ package com.sameerasw.airsync.presentation.ui.components.sheets -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -43,7 +38,6 @@ fun ConnectionSettingsBottomSheet( val scope = rememberCoroutineScope() val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) - val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) ModalBottomSheet( onDismissRequest = onDismissRequest, @@ -85,28 +79,12 @@ fun ConnectionSettingsBottomSheet( onCheckedChange = { enabled -> scope.launch { dataStoreManager.setBleSyncEnabled(enabled) + dataStoreManager.setBleAutoConnectEnabled(enabled) } } ) - - AnimatedVisibility( - visible = bleEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - IconToggleItem( - iconRes = R.drawable.rounded_bluetooth_searching_24, - title = stringResource(R.string.setting_auto_switch_title), - description = stringResource(R.string.setting_auto_switch_desc), - isChecked = autoConnect, - onCheckedChange = { enabled -> - scope.launch { - dataStoreManager.setBleAutoConnectEnabled(enabled) - } - } - ) - } } } } } + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f33d583d..6e78a719 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,8 +87,6 @@ Configure auto reconnect Auto re-connect Attempted when disconnected unexpectedly - Nearby connection - Use Bluetooth LE - Auto switch - Auto switch to BLE if connection lost + Switch to Nearby + Use Bluetooth LE if connection lost \ No newline at end of file