From ad7010a0e14acd6daed087992df3465c814c3895 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Sun, 5 Apr 2026 14:02:21 +0530 Subject: [PATCH] feat(android): implement connection watchdog, optimize autoconnect, and refine socket ping interval - Enable OkHttp pingInterval (10 seconds) on the client WebSocket to automatically detect half-open sockets and lost connections faster. - Introduce a connection watchdog in WebSocketUtil to monitor connection health and coordinate with the macOS server's reconnect grace timer. - Refactor AirSyncService and WakeupHandler to improve auto-start and connection reliability after reboot. - Enable directBootAware for MediaNotificationListener and AirSyncService. - Optimize network state validation and simplify network state tracking by cleaning up dead code. - Send manual disconnect signals over BLE before terminating connection to prevent ghost sessions on Mac. --- .gitignore | 2 +- app/src/main/AndroidManifest.xml | 7 +- .../viewmodel/AirSyncViewModel.kt | 19 +- .../airsync/service/AirSyncService.kt | 248 +++++++++++-- .../airsync/service/WakeupService.kt | 252 -------------- .../sameerasw/airsync/utils/DeviceInfoUtil.kt | 29 ++ .../sameerasw/airsync/utils/NetworkMonitor.kt | 14 +- .../sameerasw/airsync/utils/ServiceManager.kt | 9 +- .../airsync/utils/UDPDiscoveryManager.kt | 316 ++++++++++++++--- .../sameerasw/airsync/utils/WakeupHandler.kt | 32 +- .../airsync/utils/WebSocketMessageHandler.kt | 5 +- .../sameerasw/airsync/utils/WebSocketUtil.kt | 327 ++++++++++++------ 12 files changed, 814 insertions(+), 446 deletions(-) delete mode 100644 app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt 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 4030914d..637bdc48 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -118,6 +118,7 @@ @@ -178,15 +179,11 @@ - - - "WiFi" + networkStatus.hasVpn -> "VPN/Tailscale" + else -> "Other/Cellular" } + Log.d(TAG, "Network available: $networkType, triggering discovery") + // 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") } } @@ -234,16 +288,168 @@ class AirSyncService : Service() { } } + stopHttpServer() com.sameerasw.airsync.utils.MacDeviceStatusManager.stopMonitoring() com.sameerasw.airsync.utils.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 + + if (jsonRequest.has("data")) { + val data = jsonRequest.getJSONObject("data") + macIp = data.optString("macIP", "") + macPort = data.optInt("macPort", 6996) + macName = data.optString("macName", "Mac") + } else { + macIp = jsonRequest.optString("macIp", "") + macPort = jsonRequest.optInt("macPort", 6996) + macName = jsonRequest.optString("macName", "Mac") + } + + 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 + ) + } 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/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 0658b703..e4897e0e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -107,6 +107,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 789d135d..212fba25 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt @@ -26,11 +26,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) } @@ -59,10 +62,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 4a15335b..d46cd3c8 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 b1fb2fb5..12590008 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 @@ -92,6 +95,9 @@ object UDPDiscoveryManager { } } + @Volatile + private var lastKnownPeerIps: MutableMap> = mutableMapOf() // deviceId -> IPs + fun start(context: Context, discoveryEnabled: Boolean = true) { isDiscoveryEnabled = discoveryEnabled if (isRunning) { @@ -100,6 +106,7 @@ object UDPDiscoveryManager { } isRunning = true + Log.d( TAG, "Starting UDP Discovery Manager (Discovery: $isDiscoveryEnabled, Mode: $currentMode)" @@ -108,7 +115,8 @@ object UDPDiscoveryManager { acquireMulticastLock(context) startListening(context) updateBroadcastingState(context) - startPruning() + startPruning(context) + startPeerExchange(context) } fun setDiscoveryMode(context: Context, mode: DiscoveryMode) { @@ -136,6 +144,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() @@ -225,50 +243,71 @@ 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 might want to respond once so the Mac knows we are here, - // essentially performing a "lazy handshake" - if (currentMode == DiscoveryMode.PASSIVE && isDiscoveryEnabled) { - CoroutineScope(Dispatchers.IO).launch { - // broadcastPresence(context) // Optional: avoid if we want to be truly silent + 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.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") - 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) { - } } @@ -490,6 +529,204 @@ 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 json = JSONObject() + json.put("type", "presence") + json.put("deviceType", "android") + json.put("id", deviceId) + json.put("name", deviceName) + json.put("ips", FilteredIpArray(filteredIps)) + 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 json = JSONObject() + json.put("type", "peerExchange") + json.put("deviceType", "android") + json.put("id", deviceId) + json.put("name", deviceName) + + // 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) } @@ -526,7 +763,7 @@ object UDPDiscoveryManager { return ips } - private fun startPruning() { + private fun startPruning(context: Context) { pruningJob = CoroutineScope(Dispatchers.IO).launch { while (isRunning) { delay(PRUNE_INTERVAL_MS) @@ -537,13 +774,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.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..8590bba6 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt @@ -84,7 +84,7 @@ object WakeupHandler { ipAddress = macIp, port = macPort, symmetricKey = encryptionKey, - manualAttempt = false, + manualAttempt = false, // wakeup is machine-initiated; don't show failure toasts onConnectionStatus = { connected -> if (connected) { Log.i(TAG, "Successfully connected after wake-up") @@ -116,20 +116,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/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index cfaf25d9..18e37763 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -394,8 +394,7 @@ object WebSocketMessageHandler { private fun handlePing(context: Context) { try { - // 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}") @@ -404,6 +403,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 c2b8a861..fac1294a 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -55,6 +55,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 @@ -71,15 +72,16 @@ object WebSocketUtil { private val _connectionStateFlow = MutableStateFlow(false) val connectionState = _connectionStateFlow.asStateFlow() + // Watchdog to monitor connection health + private val lastActivityTime = java.util.concurrent.atomic.AtomicLong(0L) + private var watchdogJob: Job? = null + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // Keep connection alive - .pingInterval( - 20, - TimeUnit.SECONDS - ) // Send ping every 20 seconds + .pingInterval(10, TimeUnit.SECONDS) // Auto ping every 10 seconds to detect dead connection .build() } @@ -119,8 +121,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 } @@ -153,6 +167,7 @@ object WebSocketUtil { handshakeCompleted.set(false) // Reset manual disconnect flag on manual attempt + isManualDisconnectPending.set(false) if (manualAttempt) { try { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) @@ -200,6 +215,7 @@ object WebSocketUtil { isConnecting.set(false) onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) + tryStartAutoReconnect(context) try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -246,6 +262,9 @@ object WebSocketUtil { updateConnectedStatus(false) isConnecting.set(true) + lastActivityTime.set(System.currentTimeMillis()) + startWatchdog(context) + try { SyncManager.performInitialSync(context) } catch (_: Exception) { @@ -287,6 +306,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) @@ -370,11 +390,12 @@ object WebSocketUtil { reason: String ) { 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() + android.widget.Toast.makeText(context.applicationContext, "Disconnected: $msg", android.widget.Toast.LENGTH_SHORT).show() } } } @@ -416,10 +437,11 @@ object WebSocketUtil { 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) { @@ -429,7 +451,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() } } } @@ -451,7 +473,6 @@ object WebSocketUtil { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - // Check manual disconnect flag before auto-reconnecting on failure CoroutineScope(Dispatchers.IO).launch { try { @@ -492,11 +513,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 @@ -636,23 +659,28 @@ 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") + fun disconnect(context: Context? = null, isManual: Boolean = true) { + Log.d(TAG, "Disconnecting WebSocket (isManual=$isManual)") updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) handshakeTimeoutJob?.cancel() + stopWatchdog() currentIpAddress = null - // Set manual disconnect flag val ctx = context ?: appContext - ctx?.let { c -> - CoroutineScope(Dispatchers.IO).launch { - try { - val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) - ds.setUserManuallyDisconnected(true) - } catch (_: Exception) { + + // Set manual disconnect flag if applicable + if (isManual) { + 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) { + } } } @@ -780,6 +808,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) @@ -829,107 +880,169 @@ object WebSocketUtil { */ private fun tryStartAutoReconnect(context: Context) { if (autoReconnectActive.get()) return // already running - autoReconnectActive.set(true) - autoReconnectStartTime = System.currentTimeMillis() - Log.d(TAG, "Starting Smart Auto-Reconnect strategy") - 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() + 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 (targetConnection != null) { - val ips = targetConnection.networkConnections.values.joinToString(",") - val port = targetConnection.port.toIntOrNull() ?: 6996 - - 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() + if (manual || !autoEnabled) { + // Suppress starting auto-reconnect if manually disconnected or disabled + return@launch + } + + kotlinx.coroutines.withContext(Dispatchers.Main) { + if (autoReconnectActive.get()) return@withContext + autoReconnectActive.set(true) + autoReconnectStartTime = System.currentTimeMillis() + Log.d(TAG, "Starting Smart Auto-Reconnect strategy") + + 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() + val currentAutoEnabled = ds.getAutoReconnectEnabled().first() + + if (currentManual || !currentAutoEnabled) { + Log.d(TAG, "Auto-reconnect cancelled: manual=$currentManual, enabled=$currentAutoEnabled") + cancelAutoReconnect() + break + } + + 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 Monitoring (Listen for presence packets in case IP changed) - UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> - if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect + // 2. Discovery Listener (Listen for presence packets in case IP changed) + suspend fun tryConnectIfAvailable(discoveredList: List) { + if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return - 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() + 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) }