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/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..d4727d14
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt
@@ -0,0 +1,488 @@
+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
+ private var heartbeatJob: Job? = null
+
+ 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()
+ stopHeartbeat()
+ 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()) {
+ stopHeartbeat()
+ _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()
+ startHeartbeat()
+ } 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 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)
+ }
+
+ private fun handleMacBattery(value: ByteArray) {
+ if (!isAuthenticated) return
+ val payload = String(value, Charsets.UTF_8)
+ val parts = payload.split(BleConstants.DELIMITER)
+ 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(BleConstants.DELIMITER)
+ 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]
+ 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, albumArt
+ )
+ }
+ }
+
+ 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..72b0ed89
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt
@@ -0,0 +1,97 @@
+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, appName: String, title: String, text: String) {
+ val payload = listOf(pkg, appName, 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,
+ audio.albumArtLite ?: ""
+ ).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/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/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..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
@@ -272,6 +272,7 @@ fun SettingsView(
}
}
+
// Integration Section
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsCategoryTitle("Integration")
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..045fb0f1
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt
@@ -0,0 +1,58 @@
+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 -> "For nearby connection"
+ BleGattServer.BleConnectionState.ADVERTISING -> "Scanning"
+ BleGattServer.BleConnectionState.CONNECTED -> "Authenticating"
+ BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected"
+ }
+
+ 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/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/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/components/sheets/ConnectionSettingsBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt
new file mode 100644
index 00000000..030fb7e4
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt
@@ -0,0 +1,90 @@
+package com.sameerasw.airsync.presentation.ui.components.sheets
+
+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)
+
+ 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)
+ dataStoreManager.setBleAutoConnectEnabled(enabled)
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
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..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
)
}
@@ -499,41 +512,35 @@ 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)
+
+ Log.d(TAG, "Sent notification removal sync for $id")
// 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}")
}
}
}
@@ -655,19 +662,15 @@ 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")
+ Log.e(TAG, "Failed to send notification")
}
} else {
Log.d(TAG, "Skipping empty notification from ${sbn.packageName}")
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 e6536f1c..b8466fb0 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}}"""
}
/**
@@ -150,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 1804b4f2..65cacb01 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,84 @@ 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,
+ albumArt: String? = null
+ ) {
+ 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 = albumArt,
+ 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 +140,7 @@ object MacDeviceStatusManager {
)
val status = MacDeviceStatus(
+ name = name,
battery = macBattery,
isPaired = isPaired,
music = macMusicInfo
@@ -81,7 +159,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 +271,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..3e3b7c8c 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
@@ -42,8 +42,14 @@ object SyncManager {
while (isActive && isSyncing.get()) {
try {
- // Check if WebSocket is connected and sync is enabled
- if (WebSocketUtil.isConnected()) {
+ // 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 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()
@@ -110,17 +116,17 @@ object SyncManager {
shouldSync = true // First time
}
- if (shouldSync && WebSocketUtil.isConnected()) {
+ if (shouldSync) {
val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context)
+ // sendMessage handles both WebSocket and BLE fallback internally
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")
+ Log.w(TAG, "Failed to sync device status (WS/BLE)")
}
}
@@ -189,17 +195,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 +231,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..e02f4ecf 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,24 @@ 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
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 +38,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 +68,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 +243,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 +257,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 +307,7 @@ object WebSocketUtil {
AirSyncWidgetProvider.updateAllWidgets(context)
} catch (_: Exception) {
}
- isConnected.set(true)
+ updateConnectedStatus(true)
isConnecting.set(false)
handshakeTimeoutJob?.cancel()
try {
@@ -364,7 +378,7 @@ object WebSocketUtil {
}
}
}
- isConnected.set(false)
+ updateConnectedStatus(false)
isSocketOpen.set(false)
isConnecting.set(false)
handshakeCompleted.set(false)
@@ -419,7 +433,7 @@ object WebSocketUtil {
}
}
}
- isConnected.set(false)
+ updateConnectedStatus(false)
isConnecting.set(false)
isSocketOpen.set(false)
handshakeCompleted.set(false)
@@ -511,17 +525,111 @@ 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
+ }
+ "notification" -> {
+ val pkg = data.optString("package")
+ val appName = data.optString("app")
+ val title = data.optString("title")
+ val body = data.optString("body")
+ BleTransportBridge.sendNotification(pkg, appName, 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()))
+ }
+ 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"),
+ albumArtLite = music.optString("albumArtLite")
+ )
+ BleTransportBridge.sendMediaState(audio)
+ }
+ return true
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending over BLE fallback: ${e.message}")
}
+ return false
}
/**
@@ -530,7 +638,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 +720,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 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d752d8c8..6e78a719 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -81,4 +81,12 @@
Auto
App icon credits: @Syntrop2k2 on Telegram
https://t.me/Syntrop2k2
+
+
+ Reconnect
+ Configure auto reconnect
+ Auto re-connect
+ Attempted when disconnected unexpectedly
+ Switch to Nearby
+ Use Bluetooth LE if connection lost
\ No newline at end of file