From df8490f3fd32436ec371cfac23787f5e97ba9bd0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 23 Jul 2026 12:45:03 +0530 Subject: [PATCH 1/3] refactor: replace blocking operations with non-blocking coroutines across networking and data components to prevent UI thread hangs. --- .../airsync/data/ble/BleGattServer.kt | 29 ++++----- .../airsync/service/AirSyncService.kt | 61 +++++++++++-------- .../sameerasw/airsync/utils/WakeupHandler.kt | 27 ++++++-- .../airsync/utils/WebSocketMessageHandler.kt | 3 +- .../sameerasw/airsync/utils/WebSocketUtil.kt | 58 +++++++----------- .../utils/discovery/DiscoveryOrchestrator.kt | 52 ++++++++-------- 6 files changed, 121 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 62158868..37744372 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -42,10 +42,6 @@ class BleGattServer(private val context: Context) { fun isAnyAuthenticated(): Boolean = instance?.isAuthenticated ?: false } - init { - instance = this - } - private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val adapter = bluetoothManager.adapter @@ -53,6 +49,17 @@ class BleGattServer(private val context: Context) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val dataStoreManager = DataStoreManager(context) + private var isBleSyncEnabled = true + + init { + instance = this + scope.launch { + dataStoreManager.getBleSyncEnabled().collect { enabled -> + isBleSyncEnabled = enabled + } + } + } + private val _connectionState = MutableStateFlow(BleConnectionState.DISCONNECTED) val connectionState = _connectionState.asStateFlow() @@ -91,12 +98,7 @@ class BleGattServer(private val context: Context) { return } - val isEnabled = try { - runBlocking { dataStoreManager.getBleSyncEnabled().first() } - } catch (e: Exception) { - false - } - if (!isEnabled) { + if (!isBleSyncEnabled) { Log.d(TAG, "BLE Sync is disabled in settings, skipping start") return } @@ -292,12 +294,7 @@ class BleGattServer(private val context: Context) { if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false if (gattServer != null) { - val isEnabled = try { - runBlocking { dataStoreManager.getBleSyncEnabled().first() } - } catch (e: Exception) { - false - } - if (isEnabled) { + if (isBleSyncEnabled) { if (!isAdvertisingPaused) { startAdvertising() } diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 705a6f6c..31360102 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -57,6 +57,8 @@ class AirSyncService : Service() { } } + private var cachedLastDevice: com.sameerasw.airsync.domain.model.ConnectedDevice? = null + override fun onCreate() { super.onCreate() serviceInstance = this @@ -66,6 +68,14 @@ class AirSyncService : Service() { registerNetworkCallback() WebSocketUtil.registerConnectionStatusListener(connectionStatusListener) + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + scope.launch { + dataStoreManager.getLastConnectedDevice().collect { device -> + cachedLastDevice = device + updateNotification() + } + } + // Monitor connection status, auto-reconnect, and battery status to update notification live scope.launch { MacDeviceStatusManager.macDeviceStatus.collect { @@ -115,23 +125,22 @@ class AirSyncService : Service() { startForeground(NOTIFICATION_ID, buildNotification()) } - val dataStoreManager = - DataStoreManager.getInstance(applicationContext) - val isDiscoveryEnabled = runBlocking { - dataStoreManager.getDeviceDiscoveryEnabled().first() - } + scope.launch { + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + val isDiscoveryEnabled = dataStoreManager.getDeviceDiscoveryEnabled().first() - // Default to PASSIVE mode to save battery - // But do a burst to check for devices immediately - DiscoveryOrchestrator.start(this, isDiscoveryEnabled) - DiscoveryOrchestrator.setDiscoveryMode(this, DiscoveryMode.PASSIVE) - DiscoveryOrchestrator.burstBroadcast(this) + // Default to PASSIVE mode to save battery + // But do a burst to check for devices immediately + DiscoveryOrchestrator.start(this@AirSyncService, isDiscoveryEnabled) + DiscoveryOrchestrator.setDiscoveryMode(this@AirSyncService, DiscoveryMode.PASSIVE) + DiscoveryOrchestrator.burstBroadcast(this@AirSyncService) - // Start WakeupService for HTTP wakeups - WakeupService.startService(this) + // Start WakeupService for HTTP wakeups + WakeupService.startService(this@AirSyncService) - // Also trigger auto-reconnect logic to check if we already have a candidate - WebSocketUtil.requestAutoReconnect(this) + // Also trigger auto-reconnect logic to check if we already have a candidate + WebSocketUtil.requestAutoReconnect(this@AirSyncService) + } } private fun startWebDavServer() { @@ -216,19 +225,18 @@ class AirSyncService : Service() { startForeground(NOTIFICATION_ID, buildNotification()) } - val dataStoreManager = - DataStoreManager.getInstance(applicationContext) - val isDiscoveryEnabled = runBlocking { - dataStoreManager.getDeviceDiscoveryEnabled().first() - } + scope.launch { + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + val isDiscoveryEnabled = dataStoreManager.getDeviceDiscoveryEnabled().first() - // Keep discovery manager running for wake-ups even when connected - // But stay in Passive mode mostly - DiscoveryOrchestrator.start(this, isDiscoveryEnabled) - DiscoveryOrchestrator.setDiscoveryMode(this, DiscoveryMode.PASSIVE) + // Keep discovery manager running for wake-ups even when connected + // But stay in Passive mode mostly + DiscoveryOrchestrator.start(this@AirSyncService, isDiscoveryEnabled) + DiscoveryOrchestrator.setDiscoveryMode(this@AirSyncService, DiscoveryMode.PASSIVE) - WakeupService.startService(this) - monitorWebDavRequirements() + WakeupService.startService(this@AirSyncService) + monitorWebDavRequirements() + } } private fun stopSync() { @@ -319,8 +327,7 @@ class AirSyncService : Service() { val isAuto = WebSocketUtil.isAutoReconnecting() val isConnecting = WebSocketUtil.isConnecting() - val dataStoreManager = DataStoreManager.getInstance(applicationContext) - val lastDevice = runBlocking { dataStoreManager.getLastConnectedDevice().first() } + val lastDevice = cachedLastDevice val macStatus = MacDeviceStatusManager.macDeviceStatus.value if (isConnected && lastDevice != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt index 418449b8..e9a4b8ed 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt @@ -32,8 +32,8 @@ object WakeupHandler { val dataStoreManager = DataStoreManager.getInstance(context) - if (WebSocketUtil.isConnected()) { - Log.d(TAG, "Already connected, ignoring wake-up request") + if (WebSocketUtil.isWifiConnected()) { + Log.d(TAG, "Already connected via Wi-Fi, ignoring wake-up request") return } @@ -115,22 +115,39 @@ object WakeupHandler { try { val networkDevices = dataStoreManager.getAllNetworkDeviceConnections().first() val ourIp = DeviceInfoUtil.getWifiIpAddress(context) + val cleanMacName = cleanDeviceName(macName) if (ourIp != null) { val networkDevice = networkDevices.firstOrNull { device -> - device.deviceName == macName && device.getClientIpForNetwork(ourIp) == macIp + cleanDeviceName(device.deviceName) == cleanMacName && device.getClientIpForNetwork(ourIp) == macIp } if (networkDevice?.symmetricKey != null) return networkDevice.symmetricKey } val lastConnectedDevice = dataStoreManager.getLastConnectedDevice().first() - if (lastConnectedDevice?.name == macName && lastConnectedDevice.symmetricKey != null) { + if (lastConnectedDevice?.symmetricKey != null && + (cleanDeviceName(lastConnectedDevice.name) == cleanMacName || networkDevices.size <= 1) + ) { return lastConnectedDevice.symmetricKey } - return networkDevices.firstOrNull { it.deviceName == macName }?.symmetricKey + val matchedDevice = networkDevices.firstOrNull { cleanDeviceName(it.deviceName) == cleanMacName } + if (matchedDevice?.symmetricKey != null) return matchedDevice.symmetricKey + + if (networkDevices.size == 1) { + return networkDevices.first().symmetricKey + } + + return null } catch (e: Exception) { return null } } + + private fun cleanDeviceName(name: String): String { + return name + .replace("’", "'") + .trim() + .lowercase() + } } 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 07b644a8..3f9e439d 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -439,8 +439,7 @@ object WebSocketMessageHandler { private fun handleDisconnectRequest(context: Context) { try { - // Mark as intentional disconnect to prevent auto-reconnect - kotlinx.coroutines.runBlocking { + CoroutineScope(Dispatchers.IO).launch { try { val dataStoreManager = DataStoreManager(context) dataStoreManager.setUserManuallyDisconnected(true) 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 99473e5f..8d550473 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -335,42 +335,30 @@ object WebSocketUtil { updateConnectedStatus(true) isConnecting.set(false) handshakeTimeoutJob?.cancel() - try { - val ds = - com.sameerasw.airsync.data.local.DataStoreManager( - context - ) - kotlinx.coroutines.runBlocking { - ds.setUserManuallyDisconnected( - false - ) - } - } catch (_: Exception) { - } + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = + com.sameerasw.airsync.data.local.DataStoreManager.getInstance( + context + ) + ds.setUserManuallyDisconnected(false) + val lastDevice = ds.getLastConnectedDevice().first() + com.sameerasw.airsync.service.AirSyncService.start( + context, + lastDevice?.name + ) + } catch (e: Exception) { + Log.e( + TAG, + "Error starting AirSyncService: ${e.message}" + ) + } + } try { SyncManager.startPeriodicSync(context) } catch (_: Exception) { } - try { - val ds = - com.sameerasw.airsync.data.local.DataStoreManager( - context - ) - val lastDevice = kotlinx.coroutines.runBlocking { - ds.getLastConnectedDevice().first() - } - com.sameerasw.airsync.service.AirSyncService.start( - context, - lastDevice?.name - ) - } catch (e: Exception) { - Log.e( - TAG, - "Error starting AirSyncService: ${e.message}" - ) - } - onConnectionStatusChanged?.invoke(true) notifyConnectionStatusListeners(true) cancelAutoReconnect() @@ -718,12 +706,12 @@ object WebSocketUtil { // Set manual disconnect flag val ctx = context ?: appContext ctx?.let { c -> - try { - val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) - kotlinx.coroutines.runBlocking { + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) ds.setUserManuallyDisconnected(true) + } catch (_: Exception) { } - } catch (_: Exception) { } // Send manual disconnect signal over BLE before disconnecting BLE client diff --git a/app/src/main/java/com/sameerasw/airsync/utils/discovery/DiscoveryOrchestrator.kt b/app/src/main/java/com/sameerasw/airsync/utils/discovery/DiscoveryOrchestrator.kt index 8c051fac..de3b4526 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/discovery/DiscoveryOrchestrator.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/discovery/DiscoveryOrchestrator.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext object DiscoveryOrchestrator { private const val TAG = "DiscoveryOrchestrator" @@ -145,32 +146,35 @@ object DiscoveryOrchestrator { return } - val ds = DataStoreManager.getInstance(context) - val expandNetworkingEnabled = try { - runBlocking { ds.getExpandNetworkingEnabled().first() } - } catch (e: Exception) { - true - } + CoroutineScope(Dispatchers.IO).launch { + val ds = DataStoreManager.getInstance(context) + val expandNetworkingEnabled = try { + ds.getExpandNetworkingEnabled().first() + } catch (e: Exception) { + true + } - // Configure backend modes - mdnsBackend.setModeWithContext(context, currentMode) - udpBackend.setModeWithContext(context, currentMode) - - // API 32+ gets the clean mDNS flow as primary - if (android.os.Build.VERSION.SDK_INT >= 32) { - mdnsBackend.start(context) - } else { - // Older APIs run mDNS passively for discovery only (no advertiser registration) - mdnsBackend.setModeWithContext(context, DiscoveryMode.PASSIVE) - mdnsBackend.start(context) - } + withContext(Dispatchers.Main) { + // Configure backend modes + mdnsBackend.setModeWithContext(context, currentMode) + udpBackend.setModeWithContext(context, currentMode) + + // API 32+ gets the clean mDNS flow as primary + if (android.os.Build.VERSION.SDK_INT >= 32) { + mdnsBackend.start(context) + } else { + // Older APIs run mDNS passively for discovery only (no advertiser registration) + mdnsBackend.setModeWithContext(context, DiscoveryMode.PASSIVE) + mdnsBackend.start(context) + } - // Start UDP if API is low OR Tailscale is enabled - val shouldStartUdp = expandNetworkingEnabled || android.os.Build.VERSION.SDK_INT < 32 - if (shouldStartUdp) { - udpBackend.start(context) - } else { - udpBackend.stop(context) + // UDP runs if expand networking is enabled + if (expandNetworkingEnabled) { + udpBackend.start(context) + } else { + udpBackend.stop(context) + } + } } } } From 76d4d26204e406919fdcff990595a3652350358c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 23 Jul 2026 12:51:47 +0530 Subject: [PATCH 2/3] refactor: decouple Wi-Fi WebSocket reconnection and disconnection logic from BLE state --- .../viewmodel/AirSyncViewModel.kt | 10 +++--- .../airsync/service/AirSyncService.kt | 4 ++- .../sameerasw/airsync/utils/WebSocketUtil.kt | 31 +++++-------------- 3 files changed, 16 insertions(+), 29 deletions(-) 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 ede86d00..bf498633 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 @@ -853,9 +853,11 @@ class AirSyncViewModel( val target = hasNetworkAwareMappingForLastDevice() if (currentIp == "No Wi-Fi" || currentIp == "Unknown") { - // No usable Wi‑Fi: ensure we stop any active connection and do not attempt reconnect + // No usable Wi‑Fi: ensure we stop any active Wi-Fi WebSocket connection without affecting BLE try { - WebSocketUtil.disconnect(context) + if (WebSocketUtil.isWifiConnected()) { + WebSocketUtil.disconnect(context) + } } catch (_: Exception) { } // Stop service if needed @@ -875,8 +877,8 @@ class AirSyncViewModel( updatePort(target.port) updateSymmetricKey(target.symmetricKey) - // If connected/connecting to old network, disconnect first to force a clean switch - if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) { + // If Wi-Fi WebSocket is connected to old network, disconnect it first + if (WebSocketUtil.isWifiConnected()) { try { WebSocketUtil.disconnect(context) } catch (_: Exception) { diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 31360102..f6d6afd0 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -268,9 +268,11 @@ class AirSyncService : Service() { // Refresh UDP socket to bind to new network interface DiscoveryOrchestrator.refreshSocket() // When network becomes available, do a burst to announce ourselves - if (isScanning) { + if (isScanning && !com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { DiscoveryOrchestrator.burstBroadcast(applicationContext) WebSocketUtil.requestAutoReconnect(applicationContext) + } else if (isScanning) { + DiscoveryOrchestrator.burstBroadcast(applicationContext) } } } 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 8d550473..457ebc3c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -713,25 +713,6 @@ object WebSocketUtil { } catch (_: Exception) { } } - - // Send manual disconnect signal over BLE before disconnecting BLE client - try { - val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() - if (ble != null && ble.isAuthenticated) { - Log.d(TAG, "Sending manual disconnect signal over BLE before disconnecting") - ble.sendChunkedNotification( - BleConstants.CHAR_MAC_CONTROL, - "remote|manual_disconnect" - ) - - CoroutineScope(Dispatchers.IO).launch { - delay(300) - ble.disconnectAllConnectedDevices() - } - } - } catch (e: Exception) { - Log.e(TAG, "Error sending manual disconnect signal over BLE: ${e.message}") - } } webSocket?.close(1000, "Manual disconnection") @@ -904,7 +885,9 @@ object WebSocketUtil { if (autoReconnectActive.get()) return // already running autoReconnectActive.set(true) autoReconnectStartTime = System.currentTimeMillis() - notifyConnectionStatusListeners(false) + if (!com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { + notifyConnectionStatusListeners(false) + } Log.d(TAG, "Starting Smart Auto-Reconnect strategy") autoReconnectJob?.cancel() @@ -913,10 +896,10 @@ object WebSocketUtil { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) acquireWifiLock(context) - // 1. Retry Loop (Try last known IPs immediately and periodically) + // 1. Retry Loop (Try last known IPs immediately and periodically) launch { var backoffMs = 2000L - while (autoReconnectActive.get() && !isConnected.get()) { + while (autoReconnectActive.get() && !isConnected()) { val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() @@ -969,7 +952,7 @@ object WebSocketUtil { // 2. Discovery Monitoring (Listen for presence packets in case IP changed) DiscoveryOrchestrator.discoveredDevices.collect { discoveredList -> - if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect + if (!autoReconnectActive.get() || isConnected() || isConnecting.get()) return@collect val last = ds.getLastConnectedDevice().first() ?: return@collect @@ -1010,7 +993,7 @@ object WebSocketUtil { // Public wrapper to request auto-reconnect from app logic (e.g., network changes) fun requestAutoReconnect(context: Context) { // Only if not already connected or connecting - if (isConnected.get() || isConnecting.get()) return + if (isConnected() || isConnecting.get()) return tryStartAutoReconnect(context) } } From 01d6cdab5dd0e1701c00d6f1a2e8c20d6f3fa7de Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 23 Jul 2026 13:25:02 +0530 Subject: [PATCH 3/3] feat: Match BLE name to WiFi --- app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt | 1 + .../sameerasw/airsync/data/ble/BleTransportBridge.kt | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index f82b6e29..c8838291 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -12,6 +12,7 @@ class AirSyncApp : Application() { companion object { private var instance: AirSyncApp? = null + fun getContext(): Application? = instance fun isAppForeground(): Boolean = instance?.isForeground() ?: false fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = instance?.bleConnectionManager diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index b11a7d9c..f46d2b06 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -72,9 +72,14 @@ object BleTransportBridge { gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) } - fun sendDeviceName() { - val name = android.os.Build.MODEL - gattServer?.sendChunkedNotification(BleConstants.CHAR_DEVICE_NAME, name) + fun sendDeviceName(context: android.content.Context? = null) { + val ctx = context ?: com.sameerasw.airsync.AirSyncApp.getContext() ?: return + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + val dataStoreManager = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(ctx) + val persistedName = dataStoreManager.getDeviceName().first().ifBlank { null } + val name = persistedName ?: com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceName(ctx) + gattServer?.sendChunkedNotification(BleConstants.CHAR_DEVICE_NAME, name) + } } // --- Inbound (Mac -> Android) ---