diff --git a/.gitignore b/.gitignore index 9939654b..e5da0871 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ app/release local.properties .vscode/launch.json build/reports/problems/problems-report.html -.agents/ \ No newline at end of file +.agents/ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5cd44fcb..2b846e0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -125,6 +125,7 @@ @@ -192,6 +193,7 @@ () private val characteristicQueues = mutableMapOf>() private val isSending = mutableMapOf() + private val preparedWrites = java.util.concurrent.ConcurrentHashMap() var isAuthenticated = false private set @@ -280,7 +281,6 @@ class BleGattServer(private val context: Context) { fun resumeAdvertising() { if (!isAdvertisingPaused) return if (gattServer == null) return - if (_connectionState.value == BleConnectionState.DISCONNECTED) return Log.d(TAG, "BLE advertising resumed") isAdvertisingPaused = false startAdvertising() @@ -308,6 +308,7 @@ class BleGattServer(private val context: Context) { Log.d(TAG, "Device connected: ${device.address}") connectedDevices.add(device) _connectionState.value = BleConnectionState.CONNECTED + stopAdvertising() } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { Log.d(TAG, "Device disconnected: ${device.address}") connectedDevices.remove(device) @@ -376,8 +377,7 @@ class BleGattServer(private val context: Context) { offset: Int, value: ByteArray ) { - Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}") - + Log.d(TAG, "Write request for characteristic=${characteristic.uuid}, fromDevice=${device.address}, requestId=$requestId, preparedWrite=$preparedWrite, responseNeeded=$responseNeeded, offset=$offset, valueLength=${value.size}") if (characteristic.uuid != BleConstants.CHAR_AUTH_TOKEN && !isAuthenticated) { Log.w( TAG, @@ -395,6 +395,15 @@ class BleGattServer(private val context: Context) { return } + if (preparedWrite) { + val key = "${device.address}_${characteristic.uuid}" + val bos = preparedWrites.getOrPut(key) { java.io.ByteArrayOutputStream() } + bos.write(value) + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + return + } when (characteristic.uuid) { BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) @@ -504,25 +513,75 @@ class BleGattServer(private val context: Context) { // This is crucial for sequential chunk sending processNextInQueues() } + + override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) { + Log.d(TAG, "onExecuteWrite: device=${device.address}, requestId=$requestId, execute=$execute") + if (execute) { + val keys = preparedWrites.keys().toList() + for (key in keys) { + if (key.startsWith(device.address)) { + val bos = preparedWrites.remove(key) ?: continue + val value = bos.toByteArray() + val uuidStr = key.substring(device.address.length + 1) + val uuid = UUID.fromString(uuidStr) + val characteristic = findCharacteristic(uuid) + if (characteristic != null) { + Log.d(TAG, "Executing prepared write for characteristic=$uuid, valueLength=${value.size}") + scope.launch { + when (uuid) { + BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) + BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) + BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(uuid, value) { handleMacMediaState(it) } + BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(uuid, value) { + Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}") + ClipboardSyncManager.handleClipboardUpdate(context, it) + } + BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(uuid, value) { + Log.d(TAG, "Received Mac Device Name: $it") + MacDeviceStatusManager.updateMacStatus(context, name = it) + } + BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(uuid, value) { handleNotificationDismiss(it) } + } + } + } + } + } + } else { + val keys = preparedWrites.keys().toList() + for (key in keys) { + if (key.startsWith(device.address)) { + preparedWrites.remove(key) + } + } + } + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } } 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}" - ) - + Log.d(TAG, "Handling auth request from ${device.address}. DB device details: name=${deviceData?.name}, ip=${deviceData?.ipAddress}, port=${deviceData?.port}, storedKeyExists=${storedKey != null}") if (storedKey != null) { + Log.d(TAG, "Stored symmetric key found: $storedKey") 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))) { + val expectedTokenBytes = expectedToken.toByteArray(Charsets.UTF_8) + val expectedTokenBase64 = android.util.Base64.encodeToString(expectedTokenBytes, android.util.Base64.NO_WRAP) + val receivedTokenBase64 = android.util.Base64.encodeToString(token, android.util.Base64.NO_WRAP) + + Log.d(TAG, "Expected token string: '$expectedToken'") + Log.d(TAG, "Expected token base64: $expectedTokenBase64") + Log.d(TAG, "Received token string: '$receivedTokenStr'") + Log.d(TAG, "Received token base64: $receivedTokenBase64") + + val isMatch = token.contentEquals(expectedTokenBytes) + Log.d(TAG, "Performing byte-by-byte authentication token comparison. Match result: $isMatch") + + if (isMatch) { Log.i(TAG, "BLE Auth Success!") isAuthenticated = true _connectionState.value = BleConnectionState.AUTHENTICATED @@ -533,11 +592,8 @@ class BleGattServer(private val context: Context) { BleTransportBridge.sendDeviceName() startHeartbeat() } else { - Log.w(TAG, "BLE Auth Failed! Token mismatch.") - sendNotification( - BleConstants.CHAR_AUTH_RESULT, - byteArrayOf(BleConstants.AUTH_FAILED) - ) + Log.w(TAG, "BLE Auth Failed! Token mismatch (byte-level 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.") 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..d58208a2 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 @@ -22,7 +22,7 @@ object BleTransportBridge { return try { val md = MessageDigest.getInstance("SHA-256") val hash = md.digest(symmetricKey.toByteArray(Charsets.UTF_8)) - Base64.getEncoder().encodeToString(hash.copyOf(16)) + Base64.getEncoder().encodeToString(hash.copyOf(12)) } catch (e: Exception) { Log.e(TAG, "Error deriving auth token: ${e.message}") "" diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt index 85d03b90..7dcd3f88 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt @@ -162,6 +162,8 @@ fun AirSyncMainScreen( if (!uiState.isOnboardingCompleted) { hasSeenWelcomeThisSession = true + } else { + hasSeenWelcomeThisSession = false } // Volume & Media state 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 330f0ee7..9f169d7d 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 @@ -39,6 +39,7 @@ class AirSyncViewModel( ) : ViewModel() { companion object { + private const val TAG = "AirSyncViewModel" fun create(context: Context): AirSyncViewModel { val dataStoreManager = DataStoreManager(context) val repository = AirSyncRepositoryImpl(dataStoreManager) @@ -131,6 +132,13 @@ class AirSyncViewModel( } init { + // Clear manual disconnect flag on app startup so auto-reconnect works + viewModelScope.launch { + try { + repository.setUserManuallyDisconnected(false) + } catch (_: Exception) {} + } + // Register for WebSocket connection status updates WebSocketUtil.registerConnectionStatusListener(connectionStatusListener) try { @@ -229,6 +237,9 @@ class AirSyncViewModel( appContext?.unregisterReceiver(powerSaveReceiver) } catch (_: IllegalArgumentException) { // Receiver was not registered + } catch (e: Exception) { + // Context may be invalid (Activity leaked) + Log.e(TAG, "Failed to unregister receiver: ${e.message}") } } @@ -768,7 +779,7 @@ class AirSyncViewModel( fun startNetworkMonitoring(context: Context) { if (isNetworkMonitoringActive) return isNetworkMonitoringActive = true - previousNetworkIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "Unknown" + previousNetworkIp = DeviceInfoUtil.getWifiIpAddress(context) ?: DeviceInfoUtil.getLocalIpAddress() ?: "Unknown" viewModelScope.launch { try { @@ -799,13 +810,16 @@ class AirSyncViewModel( if (currentIp == "No Wi-Fi" || currentIp == "Unknown") { // No usable Wi‑Fi: ensure we stop any active connection and do not attempt reconnect try { - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, isManual = false) } catch (_: Exception) { } // Stop service if needed ServiceManager.updateServiceState(context) _uiState.value = - _uiState.value.copy(isConnected = false, isConnecting = false) + _uiState.value.copy( + isConnected = com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated(), + isConnecting = false + ) return@collect } else { // Ensure service state is updated @@ -822,7 +836,7 @@ class AirSyncViewModel( // If connected/connecting to old network, disconnect first to force a clean switch if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) { try { - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, isManual = false) } catch (_: Exception) { } } @@ -881,7 +895,7 @@ class AirSyncViewModel( // No mapping for this network: disconnect if connected and, if allowed, start generic auto-reconnect if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) { try { - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, isManual = false) } 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 d7850963..71aa4be9 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -14,6 +14,11 @@ import android.net.NetworkRequest import android.os.Build import android.os.IBinder import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket import androidx.core.app.NotificationCompat import com.sameerasw.airsync.MainActivity import com.sameerasw.airsync.R @@ -28,10 +33,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.json.JSONObject /** * Foreground service that maintains the airsync connection and handles discovery. @@ -46,6 +54,8 @@ class AirSyncService : Service() { private var webDavServer: WebDavServer? = null private var webDavJob: Job? = null + private var httpServerSocket: ServerSocket? = null + private var isHttpServerRunning = false // Network state tracking private var networkCallback: ConnectivityManager.NetworkCallback? = null @@ -104,23 +114,35 @@ class AirSyncService : Service() { isScanning = true connectedDeviceName = null startForeground(NOTIFICATION_ID, buildNotification()) - - val dataStoreManager = - DataStoreManager.getInstance(applicationContext) - val isDiscoveryEnabled = runBlocking { - dataStoreManager.getDeviceDiscoveryEnabled().first() + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + + // Start with default of true to avoid blocking the thread during boot + UDPDiscoveryManager.start(this, true) + UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.ACTIVE) + UDPDiscoveryManager.burstBroadcast(this) + + // Update asynchronously + scope.launch { + try { + val isDiscoveryEnabled = dataStoreManager.getDeviceDiscoveryEnabled().first() + if (!isDiscoveryEnabled) { + UDPDiscoveryManager.setDiscoveryEnabled(this@AirSyncService, false) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to read discovery preference", e) + } } - // Default to PASSIVE mode to save battery - // But do a burst to check for devices immediately - UDPDiscoveryManager.start(this, isDiscoveryEnabled) - UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.PASSIVE) - UDPDiscoveryManager.burstBroadcast(this) + scope.launch { + delay(60_000L) + if (isScanning) { + Log.d(TAG, "Switching from ACTIVE to PASSIVE discovery after 60s") + UDPDiscoveryManager.setDiscoveryMode(applicationContext, DiscoveryMode.PASSIVE) + } + } - // Start WakeupService for HTTP wakeups - WakeupService.startService(this) + startHttpServer() - // Also trigger auto-reconnect logic to check if we already have a candidate WebSocketUtil.requestAutoReconnect(this) } @@ -182,19 +204,13 @@ class AirSyncService : Service() { isScanning = false startForeground(NOTIFICATION_ID, buildNotification()) - val dataStoreManager = - DataStoreManager.getInstance(applicationContext) - val isDiscoveryEnabled = runBlocking { - dataStoreManager.getDeviceDiscoveryEnabled().first() - } + val dataStoreManager = DataStoreManager.getInstance(applicationContext) - // Keep discovery manager running for wake-ups even when connected - // But stay in Passive mode mostly - UDPDiscoveryManager.start(this, isDiscoveryEnabled) - UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.PASSIVE) + // Stop discovery completely while connected — it restarts in ACTIVE mode when startScanning() is called + UDPDiscoveryManager.stop(this) - WakeupService.startService(this) monitorWebDavRequirements() + startHttpServer() } private fun stopSync() { @@ -204,7 +220,8 @@ class AirSyncService : Service() { stopWebDavServer() ShortcutUtil.refreshShortcuts(this, false) UDPDiscoveryManager.stop(this) - WakeupService.stopService(this) + // NOTE: HTTP server intentionally kept running so the Mac's /wakeup POST + // can still be received after a disconnect. It is stopped in onDestroy(). stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } @@ -219,17 +236,32 @@ class AirSyncService : Service() { networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - Log.d( - TAG, - "Network available, triggering burst broadcast and refreshing socket" - ) + val caps = connectivityManager.getNetworkCapabilities(network) + val networkType = when { + caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "WiFi" + caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true -> "VPN/Tailscale" + caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "Ethernet" + else -> "Other/Cellular" + } + Log.d(TAG, "Network available: $networkType, triggering burst broadcast and refreshing socket") // Refresh UDP socket to bind to new network interface UDPDiscoveryManager.refreshSocket() - // When network becomes available, do a burst to announce ourselves - if (isScanning) { - UDPDiscoveryManager.burstBroadcast(applicationContext) - WebSocketUtil.requestAutoReconnect(applicationContext) - } + // Burst broadcast to announce presence + UDPDiscoveryManager.burstBroadcast(applicationContext) + // Trigger auto-reconnect for any known peers + WebSocketUtil.requestAutoReconnect(applicationContext) + } + + override fun onLost(network: Network) { + Log.d(TAG, "Network lost, triggering peer exchange") + // Trigger peer exchange to find peers via alternative routes + com.sameerasw.airsync.utils.UDPDiscoveryManager.triggerPeerExchange(this@AirSyncService) + } + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + val hasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + val hasVpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + Log.d(TAG, "Network capabilities changed - WiFi: $hasWifi, VPN: $hasVpn") } } @@ -345,16 +377,172 @@ class AirSyncService : Service() { } stopWebDavServer() + stopHttpServer() MacDeviceStatusManager.stopMonitoring() MacDeviceStatusManager.cleanup(this) scope.coroutineContext.cancel() super.onDestroy() } + private fun startHttpServer() { + if (isHttpServerRunning) return + scope.launch(Dispatchers.IO) { + try { + isHttpServerRunning = true + httpServerSocket = ServerSocket(HTTP_SERVER_PORT) + Log.i(TAG, "Wake-up HTTP server started on port $HTTP_SERVER_PORT") + + while (isHttpServerRunning && httpServerSocket?.isClosed == false) { + try { + val clientSocket = httpServerSocket?.accept() + if (clientSocket != null) { + launch(Dispatchers.IO) { + handleHttpRequest(clientSocket) + } + } + } catch (e: Exception) { + if (isHttpServerRunning) { + Log.e(TAG, "Error accepting HTTP connection", e) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start HTTP server", e) + } + } + } + + private fun stopHttpServer() { + isHttpServerRunning = false + try { + httpServerSocket?.close() + } catch (e: Exception) { + Log.w(TAG, "Error closing HTTP server socket", e) + } + httpServerSocket = null + Log.i(TAG, "Wake-up HTTP server stopped") + } + + private suspend fun handleHttpRequest(clientSocket: Socket) { + withContext(Dispatchers.IO) { + try { + clientSocket.use { socket -> + val input = BufferedReader(InputStreamReader(socket.getInputStream())) + val output = PrintWriter(socket.getOutputStream(), true) + + val requestLine = input.readLine() + if (requestLine == null) return@withContext + + val parts = requestLine.split(" ") + if (parts.size < 3) return@withContext + + val method = parts[0] + val path = parts[1] + + var contentLength = 0 + var line: String? + while (input.readLine().also { line = it } != null) { + if (line!!.isEmpty()) break + if (line.lowercase().startsWith("content-length:")) { + contentLength = line.substring(15).trim().toIntOrNull() ?: 0 + } + } + + if (method == "POST" && path == WAKEUP_ENDPOINT) { + val body = if (contentLength > 0) { + val bodyChars = CharArray(contentLength) + input.read(bodyChars, 0, contentLength) + String(bodyChars) + } else { + "" + } + + Log.d(TAG, "Received HTTP wake-up request: $body") + + try { + val jsonRequest = JSONObject(body) + + val macIp: String + val macPort: Int + val macName: String + val isManual: Boolean + + if (jsonRequest.has("data")) { + val data = jsonRequest.getJSONObject("data") + macIp = data.optString("macIP", "") + macPort = data.optInt("macPort", 6996) + macName = data.optString("macName", "Mac") + isManual = data.optBoolean("isManual", false) + } else { + macIp = jsonRequest.optString("macIp", "") + macPort = jsonRequest.optInt("macPort", 6996) + macName = jsonRequest.optString("macName", "Mac") + isManual = jsonRequest.optBoolean("isManual", false) + } + + val response = + """{"status": "success", "message": "Wake-up request received"}""" + sendHttpResponse(output, 200, "OK", response) + + com.sameerasw.airsync.utils.WakeupHandler.processWakeupRequest( + this@AirSyncService, + macIp, + macPort, + macName, + isManual + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing wake-up request", e) + val response = """{"status": "error", "message": "Invalid JSON"}""" + sendHttpResponse(output, 400, "Bad Request", response) + } + } else if (method == "OPTIONS") { + sendCorsResponse(output) + } else { + val response = + """{"status": "error", "message": "Method not allowed or path not found"}""" + sendHttpResponse(output, 405, "Method Not Allowed", response) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error handling HTTP request", e) + } + } + } + + private fun sendHttpResponse( + output: PrintWriter, + statusCode: Int, + statusText: String, + body: String + ) { + output.println("HTTP/1.1 $statusCode $statusText") + output.println("Content-Type: application/json") + output.println("Access-Control-Allow-Origin: *") + output.println("Access-Control-Allow-Methods: POST, OPTIONS") + output.println("Access-Control-Allow-Headers: Content-Type") + output.println("Content-Length: ${body.length}") + output.println() + output.print(body) + output.flush() + } + + private fun sendCorsResponse(output: PrintWriter) { + output.println("HTTP/1.1 200 OK") + output.println("Access-Control-Allow-Origin: *") + output.println("Access-Control-Allow-Methods: POST, OPTIONS") + output.println("Access-Control-Allow-Headers: Content-Type") + output.println("Content-Length: 0") + output.println() + output.flush() + } + companion object { private const val TAG = "AirSyncService" private const val CHANNEL_ID = "airsync_connection_channel" private const val NOTIFICATION_ID = 4001 + private const val HTTP_SERVER_PORT = 8888 + private const val WAKEUP_ENDPOINT = "/wakeup" const val ACTION_START_SCANNING = "com.sameerasw.airsync.START_SCANNING" const val ACTION_START_SYNC = "com.sameerasw.airsync.START_SYNC" 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 ed8c31c5..685f9013 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -61,6 +61,19 @@ class MediaNotificationListener : NotificationListenerService() { SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) } + fun hasSignificantMediaChange(old: MediaInfo?, new: MediaInfo?): Boolean { + if (old == null && new == null) return false + if (old == null || new == null) return true + return old.isPlaying != new.isPlaying || + old.title != new.title || + old.artist != new.artist || + old.albumArt != new.albumArt || + old.albumArtLite != new.albumArtLite || + old.durationMs != new.durationMs || + old.isBuffering != new.isBuffering || + old.likeStatus != new.likeStatus + } + // In-memory cache of like status per track key private val likeStatusCache = LinkedHashMap(32, 0.75f, true) @@ -472,7 +485,7 @@ class MediaNotificationListener : NotificationListenerService() { updateMediaInfo() // If media info changed, trigger sync - if (previousMediaInfo != currentMediaInfo) { + if (hasSignificantMediaChange(previousMediaInfo, currentMediaInfo)) { Log.d(TAG, "Media info changed, triggering sync") SyncManager.onMediaStateChanged(this) } @@ -527,7 +540,7 @@ class MediaNotificationListener : NotificationListenerService() { updateMediaInfo() // If media info changed, trigger sync - if (previousMediaInfo != currentMediaInfo) { + if (hasSignificantMediaChange(previousMediaInfo, currentMediaInfo)) { Log.d(TAG, "Media info changed after notification removal, triggering sync") SyncManager.onMediaStateChanged(this) } @@ -709,7 +722,7 @@ class MediaNotificationListener : NotificationListenerService() { private fun updateMediaInfo() { currentMediaInfo = getMediaInfo(this) - Log.d(TAG, "Updated media info: $currentMediaInfo") + Log.d(TAG, "Updated media info: title=${currentMediaInfo?.title}, artist=${currentMediaInfo?.artist}, isPlaying=${currentMediaInfo?.isPlaying}") } private fun isDuplicateNotification(packageName: String, body: String?): Boolean { diff --git a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt deleted file mode 100644 index 493d6321..00000000 --- a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.sameerasw.airsync.service - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.IBinder -import android.util.Log -import com.sameerasw.airsync.utils.WakeupHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.io.BufferedReader -import java.io.InputStreamReader -import java.io.PrintWriter -import java.net.ServerSocket -import java.net.Socket - -/** - * Service that runs a lightweight HTTP server - * to receive wake-up requests from Mac clients for initiating reconnection. - * UDP wake-up requests are now handled by UDPDiscoveryManager to avoid port conflicts. - */ -class WakeupService : Service() { - companion object { - private const val TAG = "WakeupService" - private const val HTTP_PORT = 8888 // HTTP server port - private const val WAKEUP_ENDPOINT = "/wakeup" - - fun startService(context: Context) { - val intent = Intent(context, WakeupService::class.java) - context.startService(intent) - } - - fun stopService(context: Context) { - val intent = Intent(context, WakeupService::class.java) - context.stopService(intent) - } - } - - private var httpServerSocket: ServerSocket? = null - private var serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var isRunning = false - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - super.onCreate() - Log.d(TAG, "WakeupService created") - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (!isRunning) { - startWakeupListeners() - } - return START_STICKY // Restart if killed - } - - override fun onDestroy() { - super.onDestroy() - stopWakeupListeners() - serviceScope.cancel() - Log.d(TAG, "WakeupService destroyed") - } - - private fun startWakeupListeners() { - serviceScope.launch { - try { - isRunning = true - - // Start HTTP server - startHttpServer() - - Log.i(TAG, "Wake-up HTTP listener started on port $HTTP_PORT") - } catch (e: Exception) { - Log.e(TAG, "Failed to start wake-up listeners", e) - } - } - } - - private fun stopWakeupListeners() { - isRunning = false - - // Stop HTTP server - try { - httpServerSocket?.close() - } catch (e: Exception) { - Log.w(TAG, "Error closing HTTP server socket", e) - } - httpServerSocket = null - - Log.i(TAG, "Wake-up HTTP server stopped") - } - - private suspend fun startHttpServer() { - withContext(Dispatchers.IO) { - try { - httpServerSocket = ServerSocket(HTTP_PORT) - - serviceScope.launch { - while (isRunning && httpServerSocket?.isClosed == false) { - try { - val clientSocket = httpServerSocket?.accept() - if (clientSocket != null) { - // Handle client connection in a separate coroutine - launch { - handleHttpRequest(clientSocket) - } - } - } catch (e: Exception) { - if (isRunning) { - Log.e(TAG, "Error accepting HTTP connection", e) - } - } - } - } - - Log.d(TAG, "HTTP server started on port $HTTP_PORT") - } catch (e: Exception) { - Log.e(TAG, "Failed to start HTTP server", e) - } - } - } - - private suspend fun handleHttpRequest(clientSocket: Socket) { - withContext(Dispatchers.IO) { - try { - clientSocket.use { socket -> - val input = BufferedReader(InputStreamReader(socket.getInputStream())) - val output = PrintWriter(socket.getOutputStream(), true) - - // Read HTTP request line - val requestLine = input.readLine() - if (requestLine == null) return@withContext - - val parts = requestLine.split(" ") - if (parts.size < 3) return@withContext - - val method = parts[0] - val path = parts[1] - - // Read headers to find Content-Length - var contentLength = 0 - var line: String? - while (input.readLine().also { line = it } != null) { - if (line!!.isEmpty()) break // End of headers - if (line.lowercase().startsWith("content-length:")) { - contentLength = line.substring(15).trim().toIntOrNull() ?: 0 - } - } - - // Handle wake-up request - if (method == "POST" && path == WAKEUP_ENDPOINT) { - // Read request body - val body = if (contentLength > 0) { - val bodyChars = CharArray(contentLength) - input.read(bodyChars, 0, contentLength) - String(bodyChars) - } else { - "" - } - - Log.d(TAG, "Received HTTP wake-up request: $body") - - // Parse the wake-up request - try { - val jsonRequest = JSONObject(body) - - // Handle nested JSON structure from Mac - val macIp: String - val macPort: Int - val macName: String - - if (jsonRequest.has("data")) { - // Mac sends nested format: {"type": "wakeUpRequest", "data": {...}} - val data = jsonRequest.getJSONObject("data") - macIp = data.optString( - "macIP", - "" - ) // Note: Mac uses "macIP" not "macIp" - macPort = data.optInt("macPort", 6996) - macName = data.optString("macName", "Mac") - } else { - // Fallback to flat structure - macIp = jsonRequest.optString("macIp", "") - macPort = jsonRequest.optInt("macPort", 6996) - macName = jsonRequest.optString("macName", "Mac") - } - - // Send success response - val response = - """{"status": "success", "message": "Wake-up request received"}""" - sendHttpResponse(output, 200, "OK", response) - - // Process the wake-up request using centralized handler - WakeupHandler.processWakeupRequest( - this@WakeupService, - macIp, - macPort, - macName - ) - } catch (e: Exception) { - Log.e(TAG, "Error parsing wake-up request", e) - val response = """{"status": "error", "message": "Invalid JSON"}""" - sendHttpResponse(output, 400, "Bad Request", response) - } - } else if (method == "OPTIONS") { - // Handle CORS preflight - sendCorsResponse(output) - } else { - // Method not allowed or path not found - val response = - """{"status": "error", "message": "Method not allowed or path not found"}""" - sendHttpResponse(output, 405, "Method Not Allowed", response) - } - } - } catch (e: Exception) { - Log.e(TAG, "Error handling HTTP request", e) - } - } - } - - private fun sendHttpResponse( - output: PrintWriter, - statusCode: Int, - statusText: String, - body: String - ) { - output.println("HTTP/1.1 $statusCode $statusText") - output.println("Content-Type: application/json") - output.println("Access-Control-Allow-Origin: *") - output.println("Access-Control-Allow-Methods: POST, OPTIONS") - output.println("Access-Control-Allow-Headers: Content-Type") - output.println("Content-Length: ${body.length}") - output.println() // Empty line to end headers - output.print(body) - output.flush() - } - - private fun sendCorsResponse(output: PrintWriter) { - output.println("HTTP/1.1 200 OK") - output.println("Access-Control-Allow-Origin: *") - output.println("Access-Control-Allow-Methods: POST, OPTIONS") - output.println("Access-Control-Allow-Headers: Content-Type") - output.println("Content-Length: 0") - output.println() // Empty line to end headers - output.flush() - } -} \ No newline at end of file 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 403a1c42..4d237fee 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -108,6 +108,35 @@ object DeviceInfoUtil { } } + data class NetworkStatus( + val isConnected: Boolean, + val hasWifi: Boolean, + val hasVpn: Boolean + ) + + fun getNetworkStatus(context: Context): NetworkStatus { + return try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + + if (capabilities == null) { + return NetworkStatus(false, false, false) + } + + val hasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + val hasVpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + + NetworkStatus( + isConnected = true, + hasWifi = hasWifi, + hasVpn = hasVpn + ) + } catch (_: Exception) { + NetworkStatus(false, false, false) + } + } + fun getBatteryInfo(context: Context): BatteryInfo { return try { val batteryIntent = diff --git a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt index 71f5dfe2..63c05d33 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt @@ -28,11 +28,14 @@ object NetworkMonitor { val activeNetwork = connectivityManager.activeNetwork val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) - val isConnected = networkCapabilities != null && - networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val isConnected = networkCapabilities != null val isWifi = networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true - val ipAddress = if (isWifi) DeviceInfoUtil.getWifiIpAddress(context) else null + val ipAddress = if (isWifi) { + DeviceInfoUtil.getWifiIpAddress(context) + } else { + DeviceInfoUtil.getLocalIpAddress() + } return NetworkInfo(isConnected, isWifi, ipAddress) } @@ -73,10 +76,7 @@ object NetworkMonitor { } } - val request = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) - .build() + val request = NetworkRequest.Builder().build() connectivityManager.registerNetworkCallback(request, networkCallback) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt index f8aa4b6a..ff751fb8 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt @@ -36,7 +36,14 @@ object ServiceManager { fun updateServiceState(context: Context) { CoroutineScope(Dispatchers.IO).launch { if (shouldServiceRun(context)) { - AirSyncService.startScanning(context) + if (WebSocketUtil.isConnected()) { + // If already connected, start in sync mode so the notification correctly shows "Connected to X" + val dataStore = DataStoreManager.getInstance(context) + val lastDevice = dataStore.getLastConnectedDevice().first() + AirSyncService.start(context, lastDevice?.name) + } else { + AirSyncService.startScanning(context) + } } else { AirSyncService.stop(context) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt index 8465d1ae..187ec46e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -40,11 +40,13 @@ enum class DiscoveryMode { PASSIVE // Listening only (Background) } + object UDPDiscoveryManager { private const val TAG = "UDPDiscoveryManager" private const val BROADCAST_PORT = 8889 private const val PRUNE_INTERVAL_MS = 10000L private const val DEVICE_TIMEOUT_MS = 25000L + private const val PEER_EXCHANGE_INTERVAL_MS = 30000L // Exchange peer info every 30s private val _discoveredDevices = MutableStateFlow>(emptyList()) val discoveredDevices: StateFlow> = _discoveredDevices.asStateFlow() @@ -54,6 +56,7 @@ object UDPDiscoveryManager { private var broadcastJob: Job? = null private var pruningJob: Job? = null private var burstJob: Job? = null + private var peerExchangeJob: Job? = null @Volatile private var isRunning = false @@ -94,6 +97,9 @@ object UDPDiscoveryManager { } } + @Volatile + private var lastKnownPeerIps: MutableMap> = mutableMapOf() // deviceId -> IPs + fun start(context: Context, discoveryEnabled: Boolean = true) { if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { Log.d(TAG, "Skipping UDP Discovery Manager start: local network permission not granted") @@ -106,6 +112,7 @@ object UDPDiscoveryManager { } isRunning = true + Log.d( TAG, "Starting UDP Discovery Manager (Discovery: $isDiscoveryEnabled, Mode: $currentMode)" @@ -114,7 +121,8 @@ object UDPDiscoveryManager { acquireMulticastLock(context) startListening(context) updateBroadcastingState(context) - startPruning() + startPruning(context) + startPeerExchange(context) } fun setDiscoveryMode(context: Context, mode: DiscoveryMode) { @@ -146,6 +154,16 @@ object UDPDiscoveryManager { } } + fun triggerPeerExchange(context: Context) { + if (!isRunning) return + Log.d(TAG, "Manually triggering peer exchange") + CoroutineScope(Dispatchers.IO).launch { + performPeerExchange(context) + } + } + + fun getLastKnownPeerIps(): Map> = lastKnownPeerIps.toMap() + private fun updateBroadcastingState(context: Context) { broadcastJob?.cancel() @@ -182,6 +200,8 @@ object UDPDiscoveryManager { broadcastJob?.cancel() pruningJob?.cancel() burstJob?.cancel() + peerExchangeJob?.cancel() + peerExchangeJob = null releaseMulticastLock() try { @@ -253,49 +273,72 @@ object UDPDiscoveryManager { } private fun handleIncomingTraffic(context: Context, message: String, sourceIp: String?) { - try { - val json = JSONObject(message) - val type = json.optString("type") - - when (type) { - "presence" -> { - val deviceType = json.optString("deviceType") - if (deviceType == "mac") { - handlePresenceMessage(context, json, sourceIp) - - // Optimization: If we receive a presence packet in PASSIVE mode, - // we respond so the Mac knows we are here (lazy handshake) - if (currentMode == DiscoveryMode.PASSIVE && isDiscoveryEnabled) { - CoroutineScope(Dispatchers.IO).launch { - broadcastPresence(context) + CoroutineScope(Dispatchers.IO).launch { + try { + Log.d(TAG, "RAW UDP MSG from $sourceIp: $message") + var payload = message + if (payload.startsWith("AIRSYNC_WAKEUP:")) { + payload = payload.substring("AIRSYNC_WAKEUP:".length) + } + val json = JSONObject(payload) + val type = json.optString("type") + + when (type) { + "presence" -> { + val deviceType = json.optString("deviceType") + if (deviceType == "mac") { + handlePresenceMessage(context, json, sourceIp) + + // Lazy-handshake: when in PASSIVE mode and we see a Mac presence beacon, + // respond with a single unicast so the Mac knows we're alive. + // This is battery-friendly (no loop, no timer) and is the primary + // mechanism that makes the Mac auto-discover the Android when the + // AirSync app is in the background. + if (currentMode == DiscoveryMode.PASSIVE && isDiscoveryEnabled) { + val macIp = sourceIp ?: run { + val ipsArray = json.optJSONArray("ips") + if (ipsArray != null && ipsArray.length() > 0) ipsArray.getString(0) + else json.optString("ip").takeIf { it.isNotEmpty() } + } + if (macIp != null) { + // Already in an IO coroutine, no need to launch another one for unicast + sendPresenceUnicast(context, macIp) + if (!WebSocketUtil.isConnected() && !WebSocketUtil.isConnecting() && !WebSocketUtil.isManualDisconnectPending.get()) { + WebSocketUtil.requestAutoReconnect(context) + } + } } } } - } - "bye" -> { - val deviceType = json.optString("deviceType") - if (deviceType == "mac") { - val id = json.optString("id") - val currentList = _discoveredDevices.value.filter { it.id != id } - _discoveredDevices.value = currentList + "bye" -> { + val deviceType = json.optString("deviceType") + if (deviceType == "mac") { + val id = json.optString("id") + val currentList = _discoveredDevices.value.filter { it.id != id } + _discoveredDevices.value = currentList + } } - } - "wakeUpRequest" -> { - // Handle wake-up logic shifted from WakeupService - val data = if (json.has("data")) json.getJSONObject("data") else json - val macIp = data.optString("macIP", data.optString("macIp", "")) - val macPort = data.optInt("macPort", 6996) - val macName = data.optString("macName", "Mac") + "wakeUpRequest" -> { + // Handle wake-up logic shifted from WakeupService + val data = if (json.has("data")) json.getJSONObject("data") else json + val macIp = data.optString("macIP", data.optString("macIp", "")) + val macPort = data.optInt("macPort", 6996) + val macName = data.optString("macName", "Mac") + val isManual = data.optBoolean("isManual", false) + + WakeupHandler.processWakeupRequest(context, macIp, macPort, macName, isManual) + } - CoroutineScope(Dispatchers.IO).launch { - WakeupHandler.processWakeupRequest(context, macIp, macPort, macName) + "peerExchange" -> { + // Handle peer exchange for no-WiFi discovery + handlePeerExchange(context, json) } } + } catch (e: Exception) { + Log.d(TAG, "Error handling incoming traffic: ${e.message}, message=$message") } - } catch (e: Exception) { - } } @@ -420,12 +463,25 @@ object UDPDiscoveryManager { val deviceId = DeviceInfoUtil.getDeviceId(context) + val autoConnect = try { + runBlocking { ds.getAutoReconnectEnabled().first() } + } catch (e: Exception) { + true + } + val bleAutoConnect = try { + runBlocking { ds.getBleAutoConnectEnabled().first() } + } catch (e: Exception) { + true + } + val json = JSONObject() json.put("type", "presence") json.put("deviceType", "android") json.put("id", deviceId) json.put("name", deviceName) json.put("ips", FilteredIpArray(filteredLocalIps)) // Send FILTERED IPs + json.put("autoConnect", autoConnect) + json.put("bleAutoConnect", bleAutoConnect) val payload = json.toString() val data = payload.toByteArray() @@ -517,6 +573,231 @@ object UDPDiscoveryManager { } } + /** + * Send a single unicast presence packet to a specific IP (e.g., the Mac that just pinged us). + * Used in PASSIVE mode as a lazy-handshake so the Mac becomes aware we are reachable. + */ + private fun sendPresenceUnicast(context: Context, targetIp: String) { + try { + val allIps = getAllIpAddresses() + if (allIps.isEmpty()) return + + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val customName = try { + runBlocking { ds.getDeviceName().first() } + } catch (e: Exception) { "" } + + val deviceName = if (customName.isNotBlank()) customName else android.os.Build.MODEL + val expandNetworkingEnabled = try { + runBlocking { ds.getExpandNetworkingEnabled().first() } + } catch (e: Exception) { false } + + val filteredIps = if (expandNetworkingEnabled) allIps else allIps.filter { !it.startsWith("100.") } + if (filteredIps.isEmpty()) return + + val deviceId = DeviceInfoUtil.getDeviceId(context) + + val autoConnect = try { + runBlocking { ds.getAutoReconnectEnabled().first() } + } catch (e: Exception) { + true + } + val bleAutoConnect = try { + runBlocking { ds.getBleAutoConnectEnabled().first() } + } catch (e: Exception) { + true + } + + val json = JSONObject() + json.put("type", "presence") + json.put("deviceType", "android") + json.put("id", deviceId) + json.put("name", deviceName) + json.put("ips", FilteredIpArray(filteredIps)) + json.put("autoConnect", autoConnect) + json.put("bleAutoConnect", bleAutoConnect) + val payload = json.toString() + + sendUnicast(targetIp, payload) + Log.d(TAG, "Lazy-handshake: sent presence unicast to $targetIp") + } catch (e: Exception) { + Log.e(TAG, "Failed to send presence unicast: ${e.message}") + } + } + + private fun startPeerExchange(context: Context) { + peerExchangeJob?.cancel() + peerExchangeJob = CoroutineScope(Dispatchers.IO).launch { + while (isRunning) { + delay(PEER_EXCHANGE_INTERVAL_MS) + performPeerExchange(context) + } + } + } + + private fun performPeerExchange(context: Context) { + val allIps = getAllIpAddresses() + if (allIps.isEmpty()) return + + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val expandNetworkingEnabled = try { + runBlocking { ds.getExpandNetworkingEnabled().first() } + } catch (e: Exception) { true } + + val customName = try { + runBlocking { ds.getDeviceName().first() } + } catch (e: Exception) { "" } + val deviceName = if (customName.isNotBlank()) customName else android.os.Build.MODEL + val deviceId = DeviceInfoUtil.getDeviceId(context) + + val autoConnect = try { + runBlocking { ds.getAutoReconnectEnabled().first() } + } catch (e: Exception) { + true + } + val bleAutoConnect = try { + runBlocking { ds.getBleAutoConnectEnabled().first() } + } catch (e: Exception) { + true + } + + val json = JSONObject() + json.put("type", "peerExchange") + json.put("deviceType", "android") + json.put("id", deviceId) + json.put("name", deviceName) + json.put("autoConnect", autoConnect) + json.put("bleAutoConnect", bleAutoConnect) + + // Include all IPs based on settings + val ipsToSend = if (expandNetworkingEnabled) allIps else allIps.filter { !it.startsWith("100.") } + json.put("ips", FilteredIpArray(ipsToSend)) + + // Include known peer IPs from stored connections (for no-WiFi scenarios) + val knownPeers = mutableMapOf>() + try { + val connections = runBlocking { ds.getAllNetworkDeviceConnections().first() } + for (conn in connections) { + val peerIps = conn.networkConnections.values.toList() + if (peerIps.isNotEmpty()) { + knownPeers[conn.deviceName] = peerIps + } + } + } catch (e: Exception) { } + + // Build JSON object for knownPeers manually + val knownPeersJson = org.json.JSONObject() + for ((name, ips) in knownPeers) { + val ipsArray = org.json.JSONArray() + for (ip in ips) { + ipsArray.put(ip) + } + knownPeersJson.put(name, ipsArray) + } + json.put("knownPeers", knownPeersJson) + + val payload = json.toString() + + // Send to all known peer IPs (even without WiFi, we can reach Tailscale peers) + val allKnownTargetIps = mutableSetOf() + try { + val connections = runBlocking { ds.getAllNetworkDeviceConnections().first() } + for (conn in connections) { + allKnownTargetIps.addAll(conn.networkConnections.values) + } + } catch (e: Exception) { } + + // Store our IPs for peers to discover + lastKnownPeerIps[deviceId] = ipsToSend.toSet() + + // Broadcast our presence to all known peers + for (targetIp in allKnownTargetIps) { + // Skip if it's our own IP + if (ipsToSend.contains(targetIp)) continue + + // If Expanded Networking is disabled, don't ping Tailscale targets + if (!expandNetworkingEnabled && targetIp.startsWith("100.")) continue + + sendUnicast(targetIp, payload) + } + + Log.d(TAG, "Peer exchange completed, knownPeers=${knownPeers.size}") + } + + private fun handlePeerExchange(context: Context, json: JSONObject) { + try { + val id = json.optString("id") + val name = json.optString("name") + val ipsArray = json.optJSONArray("ips") + val port = json.optInt("port", 0) + val deviceType = json.optString("deviceType") + val knownPeers = json.optJSONObject("knownPeers") + + if (id.isEmpty() || name.isEmpty() || ipsArray == null) return + + val ips = mutableSetOf() + for (i in 0 until ipsArray.length()) { + ips.add(ipsArray.getString(i)) + } + + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val expandNetworkingEnabled = try { + runBlocking { ds.getExpandNetworkingEnabled().first() } + } catch (e: Exception) { true } + + // Validate IPs + val validIps = ips.filter { ip -> + if (ip.startsWith("100.")) { + expandNetworkingEnabled || DeviceInfoUtil.getNetworkStatus(context).hasVpn + } else true + }.toSet() + + if (validIps.isEmpty()) return + + // Update peer knowledge for future connections + lastKnownPeerIps[id] = validIps + + // Also learn about peer's known peers (recursive discovery) + if (knownPeers != null && expandNetworkingEnabled) { + handleKnownPeersFromPeer(context, knownPeers) + } + + val device = DiscoveredDevice( + id = id, + name = name, + ips = validIps, + port = port, + type = deviceType, + lastSeen = System.currentTimeMillis() + ) + updateDeviceList(device, validIps) + } catch (e: Exception) { + Log.e(TAG, "Error handling peer exchange: ${e.message}") + } + } + + private fun handleKnownPeersFromPeer(context: Context, knownPeers: org.json.JSONObject) { + // When a peer sends us their known peers, store them for potential future connection + // This helps discover devices even when we're not on the same network + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + + knownPeers.keys().forEach { deviceName -> + val peerIps = knownPeers.getJSONArray(deviceName) + val ips = mutableSetOf() + for (i in 0 until peerIps.length()) { + ips.add(peerIps.getString(i)) + } + + // Store these IPs as potential connection targets + // This allows us to reach peers even if we can't broadcast + Log.d(TAG, "Learned peer $deviceName with IPs: $ips from peer exchange") + } + } catch (e: Exception) { + Log.e(TAG, "Error handling known peers: ${e.message}") + } + } + private fun FilteredIpArray(ips: List): org.json.JSONArray { val array = org.json.JSONArray() ips.forEach { array.put(it) } @@ -553,7 +834,7 @@ object UDPDiscoveryManager { return ips } - private fun startPruning() { + private fun startPruning(context: Context) { pruningJob = CoroutineScope(Dispatchers.IO).launch { while (isRunning) { delay(PRUNE_INTERVAL_MS) @@ -564,13 +845,8 @@ object UDPDiscoveryManager { _discoveredDevices.value = active } - // Smart Auto-Connect logic trigger - // When in PASSIVE mode, if we see a device we know, try to connect! - if (currentMode == DiscoveryMode.PASSIVE && active.isNotEmpty()) { - // We rely on the WebSocketUtil's existing auto-connect logic - // which might need to be notified that candidates are available - // But actually, WebSocketUtil.tryStartAutoReconnect observes _discoveredDevices - // so it should pick it up automatically if the service requested auto-connect. + if (active.isNotEmpty() && !WebSocketUtil.isConnected() && !WebSocketUtil.isConnecting() && !WebSocketUtil.isManualDisconnectPending.get()) { + WebSocketUtil.requestAutoReconnect(context) } } } 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..971f9ebe 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt @@ -20,7 +20,8 @@ object WakeupHandler { context: Context, macIp: String, macPort: Int, - macName: String + macName: String, + isManual: Boolean = false ) { try { Log.i(TAG, "Processing wake-up request from $macName at $macIp:$macPort") @@ -32,14 +33,34 @@ object WakeupHandler { val dataStoreManager = DataStoreManager.getInstance(context) - if (WebSocketUtil.isConnected()) { - Log.d(TAG, "Already connected, ignoring wake-up request") + if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) { + Log.d(TAG, "Already connected or connecting, ignoring wake-up request") return } + // Check if the user previously manually disconnected + val isManuallyDisconnected = dataStoreManager.getUserManuallyDisconnected().first() || WebSocketUtil.isManualDisconnectPending.get() + if (isManuallyDisconnected && !isManual) { + Log.d(TAG, "Ignoring wake-up request because user manually disconnected") + return + } + + if (!isManual) { + val isAutoReconnectEnabled = dataStoreManager.getAutoReconnectEnabled().first() + if (!isAutoReconnectEnabled) { + Log.d(TAG, "Ignoring wake-up request because auto-reconnect is disabled in settings") + return + } + } + // Clear manual disconnect flag since this is an external wake-up request dataStoreManager.setUserManuallyDisconnected(false) + // Reset in-memory flag so the auto-reconnect loop doesn't block this + if (isManual) { + WebSocketUtil.isManualDisconnectPending.set(false) + } + // Look up stored encryption key val encryptionKey = findStoredEncryptionKey(context, dataStoreManager, macIp, macPort, macName) @@ -78,13 +99,13 @@ object WakeupHandler { dataStoreManager.saveLastConnectedDevice(connectedDevice) } - Log.d(TAG, "Attempting to connect to Mac at $macIp:$macPort") + Log.d(TAG, "Attempting to connect to Mac at $macIp:$macPort (isManual=$isManual)") WebSocketUtil.connect( context = context, ipAddress = macIp, port = macPort, symmetricKey = encryptionKey, - manualAttempt = false, + manualAttempt = isManual, // forward manual flag so Mac's "Connect" button overrides guards onConnectionStatus = { connected -> if (connected) { Log.i(TAG, "Successfully connected after wake-up") @@ -116,20 +137,44 @@ object WakeupHandler { val networkDevices = dataStoreManager.getAllNetworkDeviceConnections().first() val ourIp = DeviceInfoUtil.getWifiIpAddress(context) + // 1. Exact match with current IP & Mac name (case-insensitive) if (ourIp != null) { val networkDevice = networkDevices.firstOrNull { device -> - device.deviceName == macName && device.getClientIpForNetwork(ourIp) == macIp + device.deviceName.equals(macName, ignoreCase = true) && device.getClientIpForNetwork(ourIp) == macIp } if (networkDevice?.symmetricKey != null) return networkDevice.symmetricKey } + // 2. Match Mac name from last connected device (case-insensitive) val lastConnectedDevice = dataStoreManager.getLastConnectedDevice().first() - if (lastConnectedDevice?.name == macName && lastConnectedDevice.symmetricKey != null) { + if (lastConnectedDevice?.name.equals(macName, ignoreCase = true) && lastConnectedDevice?.symmetricKey != null) { return lastConnectedDevice.symmetricKey } - return networkDevices.firstOrNull { it.deviceName == macName }?.symmetricKey + // 3. Match Mac name from any stored device (case-insensitive) + val nameMatchDevice = networkDevices.firstOrNull { it.deviceName.equals(macName, ignoreCase = true) } + if (nameMatchDevice?.symmetricKey != null) { + return nameMatchDevice.symmetricKey + } + + // 4. Fallback: if we only have one paired device total, use that key + if (networkDevices.size == 1) { + val singleDevice = networkDevices.first() + if (singleDevice.symmetricKey != null) { + Log.d(TAG, "Fallback: using key from the single paired device: ${singleDevice.deviceName}") + return singleDevice.symmetricKey + } + } + + // 5. Fallback: if we have a last connected device key, try it regardless of name match + if (lastConnectedDevice?.symmetricKey != null) { + Log.d(TAG, "Fallback: using key from last connected device: ${lastConnectedDevice.name}") + return lastConnectedDevice.symmetricKey + } + + return null } catch (e: Exception) { + Log.e(TAG, "Error finding stored encryption key", e) return null } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt index de098607..4a1c9711 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt @@ -14,8 +14,8 @@ import java.io.ByteArrayOutputStream object WallpaperUtil { private const val TAG = "WallpaperUtil" - private const val MAX_WALLPAPER_SIZE = 1920 // Maximum size - private const val JPEG_QUALITY = 85 // JPEG compression quality (0-100) + private const val MAX_WALLPAPER_SIZE = 800 // Maximum size + private const val JPEG_QUALITY = 60 // JPEG compression quality (0-100) /** * Gets the current wallpaper and converts it to base64 string 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..f3c8ca38 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -426,11 +426,7 @@ object WebSocketMessageHandler { private fun handlePing(context: Context) { try { // Reply immediately with lightweight pong message to keep session active - val pongJson = "{\"type\":\"pong\",\"data\":{}}" - WebSocketUtil.sendMessage(pongJson) - - // Respond to ping with current device status to keep connection alive - // We must force sync here because the server expects a response to every ping + WebSocketUtil.sendMessage("{\"type\":\"pong\",\"data\":{}}") SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) } catch (e: Exception) { Log.e(TAG, "Error handling ping: ${e.message}") @@ -439,6 +435,8 @@ object WebSocketMessageHandler { private fun handleDisconnectRequest(context: Context) { try { + // Set the manual disconnect pending flag on WebSocketUtil to suppress toasts + WebSocketUtil.isManualDisconnectPending.set(true) // Mark as intentional disconnect to prevent auto-reconnect kotlinx.coroutines.runBlocking { try { 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 0881916c..5ec3e340 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -56,6 +56,7 @@ object WebSocketUtil { // Auto-reconnect machinery private var autoReconnectJob: Job? = null private var autoReconnectActive = AtomicBoolean(false) + val isManualDisconnectPending = AtomicBoolean(false) private var autoReconnectStartTime: Long = 0L private var autoReconnectAttempts: Int = 0 @@ -72,12 +73,56 @@ object WebSocketUtil { private val _connectionStateFlow = MutableStateFlow(false) val connectionState = _connectionStateFlow.asStateFlow() - private fun createClient(): OkHttpClient { - return OkHttpClient.Builder() + // Watchdog to monitor connection health + private val lastActivityTime = java.util.concurrent.atomic.AtomicLong(0L) + private var watchdogJob: Job? = null + + private fun createClient(context: Context): OkHttpClient { + val builder = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // Keep connection alive - .build() + .pingInterval(10, TimeUnit.SECONDS) // Auto ping every 10 seconds to detect dead connection + + try { + val cm = context.getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager + if (cm != null) { + val networks = cm.allNetworks + var targetNetwork: android.net.Network? = null + + for (net in networks) { + val caps = cm.getNetworkCapabilities(net) ?: continue + if (caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI)) { + targetNetwork = net + break + } else if (caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) { + targetNetwork = net + } + } + + if (targetNetwork == null) { + val activeNet = cm.activeNetwork + if (activeNet != null) { + val caps = cm.getNetworkCapabilities(activeNet) + if (caps != null && (caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))) { + targetNetwork = activeNet + } + } + } + + if (targetNetwork != null) { + builder.socketFactory(targetNetwork.socketFactory) + Log.d(TAG, "Bound OkHttpClient to network socket factory: $targetNetwork") + } else { + Log.d(TAG, "No Wi-Fi or VPN network found to bind socket factory") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error binding OkHttpClient to specific network", e) + } + + return builder.build() } // Manual connect listeners are invoked when a user-initiated connection starts (not auto reconnect) @@ -116,8 +161,20 @@ object WebSocketUtil { // Cache application context for future cleanup even if callers don't pass context on disconnect appContext = context.applicationContext - if (isConnecting.get() || isConnected.get()) { - Log.d(TAG, "Already connected or connecting") + if (isConnecting.get()) { + if (manualAttempt) { + Log.d(TAG, "Canceling ongoing connection attempt for new manual attempt") + connectionAttemptJob?.cancel() + client?.dispatcher?.cancelAll() + isConnecting.set(false) + } else { + Log.d(TAG, "Already connecting") + return + } + } + + if (isConnected.get()) { + Log.d(TAG, "Already connected") return } @@ -152,6 +209,8 @@ object WebSocketUtil { // Reset manual disconnect flag on manual attempt if (manualAttempt) { + Log.d(TAG, "Resetting isManualDisconnectPending to false for manual connection attempt") + isManualDisconnectPending.set(false) try { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) ds.setUserManuallyDisconnected(false) @@ -182,9 +241,8 @@ object WebSocketUtil { onMessageReceived = onMessage try { - if (client == null) { - client = createClient() - } + // Recreate OkHttpClient on every connection attempt to query and bind to the latest Wi-Fi/VPN network + client = createClient(context) connectionAttemptJob?.cancel() connectionStarted.set(false) @@ -198,6 +256,7 @@ object WebSocketUtil { isConnecting.set(false) onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) + tryStartAutoReconnect(context) try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -244,6 +303,9 @@ object WebSocketUtil { updateConnectedStatus(false) isConnecting.set(true) + lastActivityTime.set(System.currentTimeMillis()) + startWatchdog(context) + try { SyncManager.performInitialSync(context) } catch (_: Exception) { @@ -285,6 +347,7 @@ object WebSocketUtil { } override fun onMessage(webSocket: WebSocket, text: String) { + lastActivityTime.set(System.currentTimeMillis()) Log.d(TAG, "RAW WebSocket message received: ${text}...") val decryptedMessage = currentSymmetricKey?.let { key -> val decrypted = CryptoUtil.decryptMessage(text, key) @@ -304,6 +367,8 @@ object WebSocketUtil { } if (handshakeOk) { handshakeCompleted.set(true) + Log.d(TAG, "Resetting isManualDisconnectPending to false on successful handshake") + isManualDisconnectPending.set(false) try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -370,17 +435,14 @@ object WebSocketUtil { code: Int, reason: String ) { + Log.d(TAG, "WebSocket onClosing entered: code=$code, reason=$reason, isManualDisconnectPending=${isManualDisconnectPending.get()}") if (webSocket == WebSocketUtil.webSocket) { - if (code != 1000) { + stopWatchdog() + if (code != 1000 && !isManualDisconnectPending.get()) { if (com.sameerasw.airsync.AirSyncApp.isAppForeground()) { CoroutineScope(Dispatchers.Main).launch { - val msg = - reason.ifEmpty { "Unknown Server Disconnect" } - android.widget.Toast.makeText( - context, - "Disconnected: $msg", - android.widget.Toast.LENGTH_SHORT - ).show() + val msg = reason.ifEmpty { "Unknown Server Disconnect" } + android.widget.Toast.makeText(context.applicationContext, "Disconnected: $msg", android.widget.Toast.LENGTH_SHORT).show() } } } @@ -423,14 +485,15 @@ object WebSocketUtil { t: Throwable, response: Response? ) { + Log.d(TAG, "WebSocket onFailure entered: message=${t.message}, responseCode=${response?.code}, isManualDisconnectPending=${isManualDisconnectPending.get()}", t) val totalToTry = ipList.size val failedCount = failedAttempts.incrementAndGet() val wasActive = webSocket == WebSocketUtil.webSocket - val isFinalManualAttempt = - manualAttempt && !connectionStarted.get() && failedCount >= totalToTry + val isFinalAttempt = !connectionStarted.get() && failedCount >= totalToTry - if (wasActive || isFinalManualAttempt) { - if (manualAttempt || isSocketOpen.get()) { + if (wasActive || isFinalAttempt) { + stopWatchdog() + if (manualAttempt && (!isSocketOpen.get() || wasActive) && !isManualDisconnectPending.get()) { if (com.sameerasw.airsync.AirSyncApp.isAppForeground()) { CoroutineScope(Dispatchers.Main).launch { val msg = when (t) { @@ -440,11 +503,7 @@ object WebSocketUtil { is java.io.EOFException, is java.net.SocketException -> "Lost connection to your mac" else -> t.message ?: "Unknown connection error" } - android.widget.Toast.makeText( - context, - "AirSync: $msg", - android.widget.Toast.LENGTH_LONG - ).show() + android.widget.Toast.makeText(context.applicationContext, "AirSync: $msg", android.widget.Toast.LENGTH_LONG).show() } } } @@ -470,20 +529,18 @@ object WebSocketUtil { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - // Check manual disconnect flag before auto-reconnecting on failure CoroutineScope(Dispatchers.IO).launch { try { - val ds = - com.sameerasw.airsync.data.local.DataStoreManager.getInstance( - context - ) - val manual = ds.getUserManuallyDisconnected().first() + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val manual = ds.getUserManuallyDisconnected().first() || isManualDisconnectPending.get() if (!manual) { tryStartAutoReconnect(context) } } catch (_: Exception) { - tryStartAutoReconnect(context) + if (!isManualDisconnectPending.get()) { + tryStartAutoReconnect(context) + } } } @@ -514,11 +571,13 @@ object WebSocketUtil { return true } - // Check standard private IP ranges (RFC 1918) and Carrier-Grade NAT (Tailscale/VPNs) - if (ipAddress.startsWith("192.168.") || ipAddress.startsWith("10.") || ipAddress.startsWith( - "100." - ) - ) { + // Check standard private IP ranges (RFC 1918) + if (ipAddress.startsWith("192.168.") || ipAddress.startsWith("10.")) { + return true + } + + // Carrier-Grade NAT (Tailscale/VPNs) - only allowed if expand networking is enabled + if (ipAddress.startsWith("100.") && expandNetworkingEnabled) { return true } // Check 172.16.0.0 to 172.31.255.255 range @@ -548,7 +607,8 @@ object WebSocketUtil { fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) if (isSocketOpen.get() && webSocket != null) { - Log.d(TAG, "Sending message via WebSocket: $message") + val logMsg = if (message.length > 250) message.substring(0, 250) + "... (truncated, total: ${message.length})" else message + Log.d(TAG, "Sending message via WebSocket: $logMsg") val messageToSend = currentSymmetricKey?.let { key -> CryptoUtil.encryptMessage(message, key) } ?: message @@ -558,7 +618,8 @@ object WebSocketUtil { // 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") + val logMsg = if (message.length > 250) message.substring(0, 250) + "... (truncated, total: ${message.length})" else message + Log.d(TAG, "WebSocket not connected, falling back to BLE: $logMsg") return sendOverBLE(message) } @@ -668,23 +729,38 @@ object WebSocketUtil { * Disconnects the WebSocket and cleans up resources. * Stops related services (AirSyncService, periodic sync) and updates UI state. */ - fun disconnect(context: Context? = null) { - Log.d(TAG, "Disconnecting WebSocket") - updateConnectedStatus(false) - isConnecting.set(false) - isSocketOpen.set(false) - handshakeCompleted.set(false) - handshakeTimeoutJob?.cancel() - currentIpAddress = null + fun disconnect(context: Context? = null, isManual: Boolean = true) { + Log.d(TAG, "Disconnecting WebSocket (isManual=$isManual)") - // Set manual disconnect flag val ctx = context ?: appContext - ctx?.let { c -> - CoroutineScope(Dispatchers.IO).launch { + + // Set manual disconnect flag if applicable + if (isManual) { + Log.d(TAG, "Setting isManualDisconnectPending to true in disconnect()") + isManualDisconnectPending.set(true) + ctx?.let { c -> + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) + ds.setUserManuallyDisconnected(true) + } catch (_: Exception) { + } + } + } + + // Send manual disconnect packet over WebSocket if open + if (isSocketOpen.get() && webSocket != null) { try { - val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) - ds.setUserManuallyDisconnected(true) - } catch (_: Exception) { + val json = org.json.JSONObject().apply { + put("type", "remoteControl") + put("data", org.json.JSONObject().apply { + put("action", "manual_disconnect") + }) + } + Log.d(TAG, "Sending manual disconnect signal over WebSocket") + sendMessage(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "Failed to send manual disconnect packet: ${e.message}") } } @@ -708,8 +784,36 @@ object WebSocketUtil { } } - webSocket?.close(1000, "Manual disconnection") + updateConnectedStatus(false) + isConnecting.set(false) + isSocketOpen.set(false) + handshakeCompleted.set(false) + handshakeTimeoutJob?.cancel() + connectionAttemptJob?.cancel() + stopWatchdog() + currentIpAddress = null + + val wsToClose = webSocket webSocket = null + if (wsToClose != null) { + if (isManual) { + CoroutineScope(Dispatchers.IO).launch { + delay(200) + try { + Log.d(TAG, "Closing WebSocket after 200ms delay for manual disconnect") + wsToClose.close(1000, "Manual disconnection") + } catch (e: Exception) { + Log.e(TAG, "Error closing websocket after delay: ${e.message}") + } + } + } else { + try { + wsToClose.close(1000, "Normal disconnection") + } catch (e: Exception) { + Log.e(TAG, "Error closing websocket: ${e.message}") + } + } + } // Transition back to scanning on disconnect ctx?.let { c -> @@ -815,6 +919,29 @@ object WebSocketUtil { } } + private fun startWatchdog(context: Context) { + watchdogJob?.cancel() + lastActivityTime.set(System.currentTimeMillis()) + watchdogJob = CoroutineScope(Dispatchers.IO).launch { + while (isConnected.get() || isSocketOpen.get()) { + delay(10000) // check every 10 seconds + val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime.get() + if (timeSinceLastActivity > 35000L) { // 35 seconds (heartbeat is 12.5s) + Log.w(TAG, "No activity from Mac for ${timeSinceLastActivity / 1000}s. Connection is stale. Disconnecting.") + CoroutineScope(Dispatchers.Main).launch { + disconnect(context, isManual = false) + } + break + } + } + } + } + + private fun stopWatchdog() { + watchdogJob?.cancel() + watchdogJob = null + } + // Public API to cancel auto reconnect (from Stop action) fun cancelAutoReconnect() { autoReconnectActive.set(false) @@ -868,117 +995,185 @@ object WebSocketUtil { * Uses a dual strategy: proactive exponential backoff AND discovery-triggered. */ private fun tryStartAutoReconnect(context: Context) { - if (autoReconnectActive.get()) return // already running - autoReconnectActive.set(true) - autoReconnectStartTime = System.currentTimeMillis() - notifyConnectionStatusListeners(false) - Log.d(TAG, "Starting Smart Auto-Reconnect strategy") + if (autoReconnectActive.get()) { + Log.d(TAG, "tryStartAutoReconnect: Auto-reconnect already active, exiting.") + return + } - autoReconnectJob?.cancel() - autoReconnectJob = CoroutineScope(Dispatchers.IO).launch { + // Check manual disconnect / auto-enabled status asynchronously before starting log and job + CoroutineScope(Dispatchers.IO).launch { try { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) - acquireWifiLock(context) - - // 1. Retry Loop (Try last known IPs immediately and periodically) - launch { - var backoffMs = 2000L - while (autoReconnectActive.get() && !isConnected.get()) { - val manual = ds.getUserManuallyDisconnected().first() - val autoEnabled = ds.getAutoReconnectEnabled().first() - - if (manual || !autoEnabled) { - Log.d( - TAG, - "Auto-reconnect cancelled: manual=$manual, enabled=$autoEnabled" - ) - cancelAutoReconnect() - break - } + val manual = ds.getUserManuallyDisconnected().first() || isManualDisconnectPending.get() + val autoEnabled = ds.getAutoReconnectEnabled().first() - if (!isConnecting.get()) { - val last = ds.getLastConnectedDevice().first() - if (last != null) { - val all = ds.getAllNetworkDeviceConnections().first() - val targetConnection = - all.firstOrNull { it.deviceName == last.name } + if (manual) { + Log.d(TAG, "tryStartAutoReconnect: Suppressing auto-reconnect because user manually disconnected (manualStore=$manual, isManualDisconnectPending=${isManualDisconnectPending.get()}).") + return@launch + } + if (!autoEnabled) { + Log.d(TAG, "tryStartAutoReconnect: Suppressing auto-reconnect because autoReconnectEnabled is false in settings.") + return@launch + } - if (targetConnection != null) { - val ips = - targetConnection.networkConnections.values.joinToString(",") - val port = targetConnection.port.toIntOrNull() ?: 6996 + kotlinx.coroutines.withContext(Dispatchers.Main) { + if (autoReconnectActive.get()) return@withContext + autoReconnectActive.set(true) + autoReconnectStartTime = System.currentTimeMillis() + Log.d(TAG, "Starting Smart Auto-Reconnect strategy") - Log.d( - TAG, - "Proactive retry to $ips:$port (backoff: ${backoffMs}ms)" - ) - connect( - context = context, - ipAddress = ips, - port = port, - symmetricKey = targetConnection.symmetricKey, - manualAttempt = false, - onConnectionStatus = { connected -> - if (connected) { - releaseWifiLock() - cancelAutoReconnect() + autoReconnectJob?.cancel() + autoReconnectJob = CoroutineScope(Dispatchers.IO).launch { + try { + acquireWifiLock(context) + + // 1. Retry Loop (Try last known IPs with exponential backoff) + launch { + var backoffMs = 2000L + while (autoReconnectActive.get() && !isConnected.get()) { + val currentManual = ds.getUserManuallyDisconnected().first() || isManualDisconnectPending.get() + val currentAutoEnabled = ds.getAutoReconnectEnabled().first() + + if (currentManual || !currentAutoEnabled) { + Log.d(TAG, "Auto-reconnect cancelled: manual=$currentManual, enabled=$currentAutoEnabled") + cancelAutoReconnect() + break + } + + val networkStatus = DeviceInfoUtil.getNetworkStatus(context) + if (!networkStatus.hasWifi && !networkStatus.hasVpn) { + Log.d(TAG, "Proactive retry skipped: no Wi-Fi or VPN available") + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(60_000L) + continue + } + + if (!isConnecting.get()) { + val last = ds.getLastConnectedDevice().first() + if (last != null) { + val ourIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "Unknown" + val all = ds.getAllNetworkDeviceConnections().first() + val targetConnection = all.firstOrNull { it.deviceName == last.name } + + val targetIps = if (targetConnection != null && ourIp != "Unknown") { + targetConnection.getClientIpForNetwork(ourIp) ?: last.ipAddress + } else { + targetConnection?.networkConnections?.values?.joinToString(",") ?: last.ipAddress + } + + if (targetIps.isNotEmpty()) { + Log.d(TAG, "Proactive retry to $targetIps (backoff: ${backoffMs}ms)") + connect( + context = context, + ipAddress = targetIps, + port = targetConnection?.port?.toIntOrNull() ?: last.port.toIntOrNull() ?: 6996, + symmetricKey = targetConnection?.symmetricKey ?: last.symmetricKey, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + CoroutineScope(Dispatchers.IO).launch { + try { + if (targetConnection != null) { + ds.updateNetworkDeviceLastConnected( + targetConnection.deviceName, + System.currentTimeMillis() + ) + } + } catch (_: Exception) {} + releaseWifiLock() + cancelAutoReconnect() + } + } + } + ) } } - ) + } + + delay(backoffMs) + // Exponential backoff capped at 1 minute + backoffMs = (backoffMs * 2).coerceAtMost(60_000L) } } - } - delay(backoffMs) - // Exponential backoff capped at 1 minute - backoffMs = (backoffMs * 2).coerceAtMost(60_000L) - } - } + // 2. Discovery Listener (Listen for presence packets in case IP changed) + suspend fun tryConnectIfAvailable(discoveredList: List) { + if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return - // 2. Discovery Monitoring (Listen for presence packets in case IP changed) - UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> - if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect - - val last = ds.getLastConnectedDevice().first() ?: return@collect - - // Match by name within the discovery list - val discoveryMatch = discoveredList.find { it.name == last.name } - if (discoveryMatch != null) { - Log.d(TAG, "Discovery-triggered reconnect for: ${discoveryMatch.name}") - - val all = ds.getAllNetworkDeviceConnections().first() - val targetConnection = all.firstOrNull { it.deviceName == last.name } - - if (targetConnection != null) { - val ips = discoveryMatch.ips.joinToString(",") - val port = targetConnection.port.toIntOrNull() ?: 6996 - - connect( - context = context, - ipAddress = ips, - port = port, - symmetricKey = targetConnection.symmetricKey, - manualAttempt = false, - onConnectionStatus = { connected -> - if (connected) { - releaseWifiLock() - cancelAutoReconnect() + val currentManual = ds.getUserManuallyDisconnected().first() || isManualDisconnectPending.get() + val currentAutoEnabled = ds.getAutoReconnectEnabled().first() + if (currentManual || !currentAutoEnabled) { + cancelAutoReconnect() + return + } + + val last = ds.getLastConnectedDevice().first() ?: return + + // Check if we have ANY network available (WiFi, VPN, or Cellular) + val networkStatus = DeviceInfoUtil.getNetworkStatus(context) + val hasNetwork = networkStatus.isConnected + + if (!hasNetwork) { + Log.d(TAG, "No network available, skipping auto-reconnect") + return + } + + // Try discovered devices first + val discoveryMatch = discoveredList.find { it.name == last.name } + if (discoveryMatch != null) { + val all = ds.getAllNetworkDeviceConnections().first() + val targetConnection = all.firstOrNull { it.deviceName == last.name } + + if (targetConnection != null) { + val ips = discoveryMatch.ips.joinToString(",") + val port = targetConnection.port.toIntOrNull() ?: 6996 + + Log.d(TAG, "Discovery-triggered reconnect for: ${discoveryMatch.name} to $ips:$port") + connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = targetConnection.symmetricKey, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + CoroutineScope(Dispatchers.IO).launch { + try { + ds.updateNetworkDeviceLastConnected( + targetConnection.deviceName, + System.currentTimeMillis() + ) + } catch (_: Exception) {} + releaseWifiLock() + cancelAutoReconnect() + } + } + } + ) } } - ) + } + + tryConnectIfAvailable(UDPDiscoveryManager.discoveredDevices.value) + UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> + if (!autoReconnectActive.get()) return@collect + tryConnectIfAvailable(discoveredList) + } + } catch (e: kotlinx.coroutines.CancellationException) { + // Coroutine was cancelled - this is normal, not an error + } catch (e: Exception) { + Log.e(TAG, "Error in smart auto-reconnect: ${e.message}") + releaseWifiLock() } } } } catch (e: Exception) { - Log.e(TAG, "Error in smart auto-reconnect: ${e.message}") - releaseWifiLock() + Log.e(TAG, "Failed pre-checking auto-reconnect: ${e.message}") } } } - // 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 tryStartAutoReconnect(context) }