Skip to content

Commit 301a6d7

Browse files
committed
feat(android): optimize media seekbar sync, clean logs, and fix BLE connection UI state
- Media Seekbar Sync & Logging Optimization: - Implement `hasSignificantMediaChange()` to ignore minor progress updates, eliminating the CPU-intensive, every-second WebSocket sync loop. - Clean up production logs by removing massive base64 album art strings from MediaNotificationListener printouts. - UI & BLE Advertising Sync: - Update AirSyncViewModel connection state to be BLE-aware; UI correctly displays "Connected" when Wi-Fi is down but BLE is active. - Dynamically pause/resume BLE advertising based on active Wi-Fi connection status. - WebSocket & Lifecycle Stability: - Enable OkHttp WebSocket client-side pingInterval (10 seconds) to detect half-open sockets faster. - Implement a connection watchdog in WebSocketUtil to coordinate with Mac reconnect grace timer. - Enable directBootAware for MediaNotificationListener and AirSyncService for reboot robustness.
1 parent 30e3c4a commit 301a6d7

16 files changed

Lines changed: 1028 additions & 518 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ app/release
3535
local.properties
3636
.vscode/launch.json
3737
build/reports/problems/problems-report.html
38-
.agents/
38+
.agents/

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
<service
126126
android:name=".service.MediaNotificationListener"
127127
android:exported="false"
128+
android:directBootAware="true"
128129
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
129130
<intent-filter>
130131
<action android:name="android.service.notification.NotificationListenerService" />
@@ -192,6 +193,7 @@
192193
<service
193194
android:name=".service.AirSyncService"
194195
android:exported="false"
196+
android:directBootAware="true"
195197
android:foregroundServiceType="connectedDevice" />
196198

197199
<service

app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class BleGattServer(private val context: Context) {
5959
private val connectedDevices = mutableSetOf<BluetoothDevice>()
6060
private val characteristicQueues = mutableMapOf<UUID, ConcurrentLinkedQueue<ByteArray>>()
6161
private val isSending = mutableMapOf<UUID, Boolean>()
62+
private val preparedWrites = java.util.concurrent.ConcurrentHashMap<String, java.io.ByteArrayOutputStream>()
6263

6364
var isAuthenticated = false
6465
private set
@@ -280,7 +281,6 @@ class BleGattServer(private val context: Context) {
280281
fun resumeAdvertising() {
281282
if (!isAdvertisingPaused) return
282283
if (gattServer == null) return
283-
if (_connectionState.value == BleConnectionState.DISCONNECTED) return
284284
Log.d(TAG, "BLE advertising resumed")
285285
isAdvertisingPaused = false
286286
startAdvertising()
@@ -308,6 +308,7 @@ class BleGattServer(private val context: Context) {
308308
Log.d(TAG, "Device connected: ${device.address}")
309309
connectedDevices.add(device)
310310
_connectionState.value = BleConnectionState.CONNECTED
311+
stopAdvertising()
311312
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
312313
Log.d(TAG, "Device disconnected: ${device.address}")
313314
connectedDevices.remove(device)
@@ -376,8 +377,7 @@ class BleGattServer(private val context: Context) {
376377
offset: Int,
377378
value: ByteArray
378379
) {
379-
Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}")
380-
380+
Log.d(TAG, "Write request for characteristic=${characteristic.uuid}, fromDevice=${device.address}, requestId=$requestId, preparedWrite=$preparedWrite, responseNeeded=$responseNeeded, offset=$offset, valueLength=${value.size}")
381381
if (characteristic.uuid != BleConstants.CHAR_AUTH_TOKEN && !isAuthenticated) {
382382
Log.w(
383383
TAG,
@@ -395,6 +395,15 @@ class BleGattServer(private val context: Context) {
395395
return
396396
}
397397

398+
if (preparedWrite) {
399+
val key = "${device.address}_${characteristic.uuid}"
400+
val bos = preparedWrites.getOrPut(key) { java.io.ByteArrayOutputStream() }
401+
bos.write(value)
402+
if (responseNeeded) {
403+
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
404+
}
405+
return
406+
}
398407
when (characteristic.uuid) {
399408
BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value)
400409
BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value)
@@ -504,25 +513,75 @@ class BleGattServer(private val context: Context) {
504513
// This is crucial for sequential chunk sending
505514
processNextInQueues()
506515
}
516+
517+
override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) {
518+
Log.d(TAG, "onExecuteWrite: device=${device.address}, requestId=$requestId, execute=$execute")
519+
if (execute) {
520+
val keys = preparedWrites.keys().toList()
521+
for (key in keys) {
522+
if (key.startsWith(device.address)) {
523+
val bos = preparedWrites.remove(key) ?: continue
524+
val value = bos.toByteArray()
525+
val uuidStr = key.substring(device.address.length + 1)
526+
val uuid = UUID.fromString(uuidStr)
527+
val characteristic = findCharacteristic(uuid)
528+
if (characteristic != null) {
529+
Log.d(TAG, "Executing prepared write for characteristic=$uuid, valueLength=${value.size}")
530+
scope.launch {
531+
when (uuid) {
532+
BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value)
533+
BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value)
534+
BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) }
535+
BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) }
536+
BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(uuid, value) { handleMacMediaState(it) }
537+
BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(uuid, value) {
538+
Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}")
539+
ClipboardSyncManager.handleClipboardUpdate(context, it)
540+
}
541+
BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(uuid, value) {
542+
Log.d(TAG, "Received Mac Device Name: $it")
543+
MacDeviceStatusManager.updateMacStatus(context, name = it)
544+
}
545+
BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(uuid, value) { handleNotificationDismiss(it) }
546+
}
547+
}
548+
}
549+
}
550+
}
551+
} else {
552+
val keys = preparedWrites.keys().toList()
553+
for (key in keys) {
554+
if (key.startsWith(device.address)) {
555+
preparedWrites.remove(key)
556+
}
557+
}
558+
}
559+
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
560+
}
507561
}
508562

509563
private fun handleAuthRequest(device: BluetoothDevice, token: ByteArray) {
510564
scope.launch {
511565
val deviceData = dataStoreManager.getLastConnectedDevice().first()
512566
val storedKey = deviceData?.symmetricKey
513-
Log.d(
514-
TAG,
515-
"Handling auth request from ${device.address}. Device in DB: ${deviceData?.name}, hasKey: ${storedKey != null}"
516-
)
517-
567+
Log.d(TAG, "Handling auth request from ${device.address}. DB device details: name=${deviceData?.name}, ip=${deviceData?.ipAddress}, port=${deviceData?.port}, storedKeyExists=${storedKey != null}")
518568
if (storedKey != null) {
569+
Log.d(TAG, "Stored symmetric key found: $storedKey")
519570
val expectedToken = BleTransportBridge.deriveAuthToken(storedKey)
520571
val receivedTokenStr = String(token, Charsets.UTF_8)
521-
522-
Log.d(TAG, "Expected token: $expectedToken")
523-
Log.d(TAG, "Received token: $receivedTokenStr")
524-
525-
if (token.contentEquals(expectedToken.toByteArray(Charsets.UTF_8))) {
572+
val expectedTokenBytes = expectedToken.toByteArray(Charsets.UTF_8)
573+
val expectedTokenBase64 = android.util.Base64.encodeToString(expectedTokenBytes, android.util.Base64.NO_WRAP)
574+
val receivedTokenBase64 = android.util.Base64.encodeToString(token, android.util.Base64.NO_WRAP)
575+
576+
Log.d(TAG, "Expected token string: '$expectedToken'")
577+
Log.d(TAG, "Expected token base64: $expectedTokenBase64")
578+
Log.d(TAG, "Received token string: '$receivedTokenStr'")
579+
Log.d(TAG, "Received token base64: $receivedTokenBase64")
580+
581+
val isMatch = token.contentEquals(expectedTokenBytes)
582+
Log.d(TAG, "Performing byte-by-byte authentication token comparison. Match result: $isMatch")
583+
584+
if (isMatch) {
526585
Log.i(TAG, "BLE Auth Success!")
527586
isAuthenticated = true
528587
_connectionState.value = BleConnectionState.AUTHENTICATED
@@ -533,11 +592,8 @@ class BleGattServer(private val context: Context) {
533592
BleTransportBridge.sendDeviceName()
534593
startHeartbeat()
535594
} else {
536-
Log.w(TAG, "BLE Auth Failed! Token mismatch.")
537-
sendNotification(
538-
BleConstants.CHAR_AUTH_RESULT,
539-
byteArrayOf(BleConstants.AUTH_FAILED)
540-
)
595+
Log.w(TAG, "BLE Auth Failed! Token mismatch (byte-level mismatch).")
596+
sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED))
541597
}
542598
} else {
543599
Log.w(TAG, "BLE Auth Failed! No symmetric key found for last connected device.")

app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ object BleTransportBridge {
2222
return try {
2323
val md = MessageDigest.getInstance("SHA-256")
2424
val hash = md.digest(symmetricKey.toByteArray(Charsets.UTF_8))
25-
Base64.getEncoder().encodeToString(hash.copyOf(16))
25+
Base64.getEncoder().encodeToString(hash.copyOf(12))
2626
} catch (e: Exception) {
2727
Log.e(TAG, "Error deriving auth token: ${e.message}")
2828
""

app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class AirSyncViewModel(
3939
) : ViewModel() {
4040

4141
companion object {
42+
private const val TAG = "AirSyncViewModel"
4243
fun create(context: Context): AirSyncViewModel {
4344
val dataStoreManager = DataStoreManager(context)
4445
val repository = AirSyncRepositoryImpl(dataStoreManager)
@@ -131,6 +132,13 @@ class AirSyncViewModel(
131132
}
132133

133134
init {
135+
// Clear manual disconnect flag on app startup so auto-reconnect works
136+
viewModelScope.launch {
137+
try {
138+
repository.setUserManuallyDisconnected(false)
139+
} catch (_: Exception) {}
140+
}
141+
134142
// Register for WebSocket connection status updates
135143
WebSocketUtil.registerConnectionStatusListener(connectionStatusListener)
136144
try {
@@ -229,6 +237,9 @@ class AirSyncViewModel(
229237
appContext?.unregisterReceiver(powerSaveReceiver)
230238
} catch (_: IllegalArgumentException) {
231239
// Receiver was not registered
240+
} catch (e: Exception) {
241+
// Context may be invalid (Activity leaked)
242+
Log.e(TAG, "Failed to unregister receiver: ${e.message}")
232243
}
233244
}
234245

@@ -768,7 +779,7 @@ class AirSyncViewModel(
768779
fun startNetworkMonitoring(context: Context) {
769780
if (isNetworkMonitoringActive) return
770781
isNetworkMonitoringActive = true
771-
previousNetworkIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "Unknown"
782+
previousNetworkIp = DeviceInfoUtil.getWifiIpAddress(context) ?: DeviceInfoUtil.getLocalIpAddress() ?: "Unknown"
772783

773784
viewModelScope.launch {
774785
try {
@@ -799,13 +810,16 @@ class AirSyncViewModel(
799810
if (currentIp == "No Wi-Fi" || currentIp == "Unknown") {
800811
// No usable Wi‑Fi: ensure we stop any active connection and do not attempt reconnect
801812
try {
802-
WebSocketUtil.disconnect(context)
813+
WebSocketUtil.disconnect(context, isManual = false)
803814
} catch (_: Exception) {
804815
}
805816
// Stop service if needed
806817
ServiceManager.updateServiceState(context)
807818
_uiState.value =
808-
_uiState.value.copy(isConnected = false, isConnecting = false)
819+
_uiState.value.copy(
820+
isConnected = com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated(),
821+
isConnecting = false
822+
)
809823
return@collect
810824
} else {
811825
// Ensure service state is updated
@@ -822,7 +836,7 @@ class AirSyncViewModel(
822836
// If connected/connecting to old network, disconnect first to force a clean switch
823837
if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) {
824838
try {
825-
WebSocketUtil.disconnect(context)
839+
WebSocketUtil.disconnect(context, isManual = false)
826840
} catch (_: Exception) {
827841
}
828842
}
@@ -881,7 +895,7 @@ class AirSyncViewModel(
881895
// No mapping for this network: disconnect if connected and, if allowed, start generic auto-reconnect
882896
if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) {
883897
try {
884-
WebSocketUtil.disconnect(context)
898+
WebSocketUtil.disconnect(context, isManual = false)
885899
} catch (_: Exception) {
886900
}
887901
}

0 commit comments

Comments
 (0)