diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 085c026..0293965 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -4,8 +4,13 @@ import android.app.Activity import android.app.Application import android.os.Bundle import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.AirBridgeClient +import com.sameerasw.airsync.utils.WebSocketMessageHandler import io.sentry.android.core.SentryAndroid +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking class AirSyncApp : Application() { @@ -20,6 +25,7 @@ class AirSyncApp : Application() { super.onCreate() instance = this initSentry() + initAirBridge() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) { @@ -48,4 +54,20 @@ class AirSyncApp : Application() { options.isEnabled = true } } + + private fun initAirBridge() { + // Wire message handler: relay messages → existing WebSocket message pipeline + AirBridgeClient.setMessageHandler { context, message -> + WebSocketMessageHandler.handleIncomingMessage(context, message) + } + + // Auto-connect if previously enabled + CoroutineScope(Dispatchers.IO).launch { + val ds = DataStoreManager.getInstance(this@AirSyncApp) + val enabled = ds.getAirBridgeEnabled().first() + if (enabled) { + AirBridgeClient.connect(this@AirSyncApp) + } + } + } } diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 8da1a07..e3b04c5 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -54,6 +55,7 @@ import com.sameerasw.airsync.presentation.ui.screens.AirSyncMainScreen import com.sameerasw.airsync.ui.theme.AirSyncTheme import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.AdbMdnsDiscovery +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.ContentCaptureManager import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.KeyguardHelper @@ -61,6 +63,7 @@ import com.sameerasw.airsync.utils.NotesRoleManager import com.sameerasw.airsync.utils.PermissionUtil import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.net.URLDecoder @@ -348,21 +351,48 @@ class MainActivity : ComponentActivity() { var pcName: String? = null var isPlus = false var symmetricKey: String? = null + var relayUrl: String? = null + var airBridgePairingId: String? = null + var airBridgeSecret: String? = null data?.let { uri -> val urlString = uri.toString() - val queryPart = urlString.substringAfter('?', "") - if (queryPart.isNotEmpty()) { - val params = queryPart.split('?') - val paramMap = params.associate { - val parts = it.split('=', limit = 2) - val key = parts.getOrNull(0) ?: "" - val value = parts.getOrNull(1) ?: "" - key to value + val paramMap = parseAirsyncParams(urlString) + pcName = paramMap["name"]?.let { android.net.Uri.decode(it) } + isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false + symmetricKey = paramMap["key"]?.let { + android.net.Uri.decode(it) + } + relayUrl = paramMap["relay"]?.let { android.net.Uri.decode(it) } + airBridgePairingId = + paramMap["pairingId"]?.let { android.net.Uri.decode(it) } + airBridgeSecret = + paramMap["secret"]?.let { android.net.Uri.decode(it) } + } + + if (!relayUrl.isNullOrBlank() && + !airBridgePairingId.isNullOrBlank() && + !airBridgeSecret.isNullOrBlank() + ) { + lifecycleScope.launch { + try { + val ds = DataStoreManager.getInstance(this@MainActivity) + ds.setAirBridgeRelayUrl(relayUrl!!) + ds.setAirBridgePairingId(airBridgePairingId!!) + ds.setAirBridgeSecret(airBridgeSecret!!) + ds.setAirBridgeEnabled(true) + + // Provide symmetric key to AirBridgeClient immediately so it doesn't + // refuse connection waiting for a completed LAN handshake first + if (!symmetricKey.isNullOrEmpty()) { + AirBridgeClient.updateSymmetricKey(symmetricKey) + } + + AirBridgeClient.disconnect() + AirBridgeClient.connect(this@MainActivity) + } catch (e: Exception) { + Log.e("MainActivity", "Failed to apply AirBridge QR config: ${e.message}", e) } - pcName = paramMap["name"]?.let { URLDecoder.decode(it, "UTF-8") } - isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false - symmetricKey = paramMap["key"] } } @@ -580,6 +610,22 @@ class MainActivity : ComponentActivity() { return AdbDiscoveryHolder.getDiscoveredServices() } + private fun parseAirsyncParams(urlString: String): Map { + val queryPart = urlString.substringAfter('?', "") + if (queryPart.isEmpty()) return emptyMap() + + return queryPart.split('?') + .mapNotNull { raw -> + if (raw.isBlank()) return@mapNotNull null + val parts = raw.split('=', limit = 2) + val key = parts.getOrNull(0)?.trim().orEmpty() + if (key.isEmpty()) return@mapNotNull null + val value = parts.getOrNull(1).orEmpty() + key to value + } + .toMap() + } + /** * Handles intents related to the Notes Role feature. * Extracts stylus mode hint and lock screen status from the intent. diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index 0e5672e..f541f5d 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt @@ -96,6 +96,12 @@ class DataStoreManager(private val context: Context) { // Widget preferences private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") + // AirBridge relay preferences + private val AIRBRIDGE_ENABLED = booleanPreferencesKey("airbridge_enabled") + private val AIRBRIDGE_RELAY_URL = stringPreferencesKey("airbridge_relay_url") + private val AIRBRIDGE_PAIRING_ID = stringPreferencesKey("airbridge_pairing_id") + private val AIRBRIDGE_SECRET = stringPreferencesKey("airbridge_secret") + private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -604,6 +610,40 @@ class DataStoreManager(private val context: Context) { } } + // --- AirBridge Relay --- + + suspend fun setAirBridgeEnabled(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[AIRBRIDGE_ENABLED] = enabled } + } + + fun getAirBridgeEnabled(): Flow { + return context.dataStore.data.map { it[AIRBRIDGE_ENABLED] ?: false } + } + + suspend fun setAirBridgeRelayUrl(url: String) { + context.dataStore.edit { prefs -> prefs[AIRBRIDGE_RELAY_URL] = url } + } + + fun getAirBridgeRelayUrl(): Flow { + return context.dataStore.data.map { it[AIRBRIDGE_RELAY_URL] ?: "" } + } + + suspend fun setAirBridgePairingId(id: String) { + context.dataStore.edit { prefs -> prefs[AIRBRIDGE_PAIRING_ID] = id } + } + + fun getAirBridgePairingId(): Flow { + return context.dataStore.data.map { it[AIRBRIDGE_PAIRING_ID] ?: "" } + } + + suspend fun setAirBridgeSecret(secret: String) { + context.dataStore.edit { prefs -> prefs[AIRBRIDGE_SECRET] = secret } + } + + fun getAirBridgeSecret(): Flow { + return context.dataStore.data.map { it[AIRBRIDGE_SECRET] ?: "" } + } + // Network-aware device connections suspend fun saveNetworkDeviceConnection( deviceName: String, diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index 79c9397..a5f7e0e 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt @@ -39,6 +39,7 @@ data class UiState( val defaultTab: String = "dynamic", val isEssentialsConnectionEnabled: Boolean = false, val activeIp: String? = null, + val isRelayConnection: Boolean = false, val connectingDeviceId: String? = null, val isDeviceDiscoveryEnabled: Boolean = true, val shouldShowRatingPrompt: Boolean = false, diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 993a4ad..02c7028 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -48,6 +48,7 @@ import com.sameerasw.airsync.presentation.ui.components.cards.MediaSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.NotificationSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.PermissionsCard import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTilesCard +import com.sameerasw.airsync.presentation.ui.components.cards.AirBridgeCard import com.sameerasw.airsync.presentation.ui.components.cards.SendNowPlayingCard import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel @@ -363,6 +364,8 @@ fun SettingsView( ) ExpandNetworkingCard(context) + + AirBridgeCard(context) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/AirBridgeCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/AirBridgeCard.kt new file mode 100644 index 0000000..5600a3a --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/AirBridgeCard.kt @@ -0,0 +1,258 @@ +package com.sameerasw.airsync.presentation.ui.components.cards + +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.AirBridgeClient +import com.sameerasw.airsync.utils.HapticUtil +import kotlinx.coroutines.launch + +// Card for AirBridge relay settings and connection status +@Composable +fun AirBridgeCard(context: Context) { + val ds = remember { DataStoreManager(context) } + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + + var enabled by remember { mutableStateOf(false) } + var relayUrl by remember { mutableStateOf("") } + var pairingId by remember { mutableStateOf("") } + var secret by remember { mutableStateOf("") } + + val connectionState by AirBridgeClient.connectionState.collectAsState() + val peerReallyActive by AirBridgeClient.peerReallyActive.collectAsState() + + LaunchedEffect(Unit) { + launch { ds.getAirBridgeEnabled().collect { enabled = it } } + launch { ds.getAirBridgeRelayUrl().collect { relayUrl = it } } + launch { ds.getAirBridgePairingId().collect { pairingId = it } } + launch { ds.getAirBridgeSecret().collect { secret = it } } + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Toggle row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("AirBridge Relay (Beta)", style = MaterialTheme.typography.titleMedium) + Text( + "Connect via relay server when not on the same network", + modifier = Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + Switch( + checked = enabled, + onCheckedChange = { newValue -> + enabled = newValue + if (newValue) HapticUtil.performToggleOn(haptics) + else HapticUtil.performToggleOff(haptics) + scope.launch { + ds.setAirBridgeEnabled(newValue) + if (newValue) { + AirBridgeClient.connect(context) + } else { + AirBridgeClient.disconnect() + } + } + } + ) + } + + // Expanded settings + AnimatedVisibility( + visible = enabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + // Connection status + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background( + when (connectionState) { + AirBridgeClient.State.DISCONNECTED -> Color.Gray + AirBridgeClient.State.CONNECTING -> Color(0xFFFFA000) + AirBridgeClient.State.CHALLENGE_RECEIVED -> Color(0xFFFFA000) + AirBridgeClient.State.REGISTERING -> Color(0xFFFFA000) + AirBridgeClient.State.WAITING_FOR_PEER -> Color(0xFFFFD600) + AirBridgeClient.State.RELAY_ACTIVE -> Color(0xFF4CAF50) + AirBridgeClient.State.FAILED -> Color.Red + } + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when (connectionState) { + AirBridgeClient.State.DISCONNECTED -> "Disconnected" + AirBridgeClient.State.CONNECTING -> "Connecting..." + AirBridgeClient.State.CHALLENGE_RECEIVED -> "Authenticating..." + AirBridgeClient.State.REGISTERING -> "Registering..." + AirBridgeClient.State.WAITING_FOR_PEER -> "Waiting for Mac..." + AirBridgeClient.State.RELAY_ACTIVE -> "Relay Active" + AirBridgeClient.State.FAILED -> "Connection Failed" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + if (connectionState == AirBridgeClient.State.RELAY_ACTIVE && !peerReallyActive) { + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color(0xFFFF9800)) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Peer offline", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFFF9800) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Relay URL + OutlinedTextField( + value = relayUrl, + onValueChange = { relayUrl = it }, + label = { Text("Relay Server URL") }, + placeholder = { Text("airbridge.yourdomain.com") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + var credentialsVisible by remember { mutableStateOf(false) } + + // Pairing ID (paste from Mac) + OutlinedTextField( + value = pairingId, + onValueChange = { pairingId = it }, + label = { Text("Pairing ID") }, + placeholder = { Text("Your Pairing ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (credentialsVisible) androidx.compose.ui.text.input.VisualTransformation.None else androidx.compose.ui.text.input.PasswordVisualTransformation(), + trailingIcon = { + androidx.compose.material3.IconButton(onClick = { credentialsVisible = !credentialsVisible }) { + Icon( + imageVector = if (credentialsVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (credentialsVisible) "Hide credentials" else "Show credentials" + ) + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Secret (paste from Mac) + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text("Secret") }, + placeholder = { Text("Your Secret") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (credentialsVisible) androidx.compose.ui.text.input.VisualTransformation.None else androidx.compose.ui.text.input.PasswordVisualTransformation(), + trailingIcon = { + androidx.compose.material3.IconButton(onClick = { credentialsVisible = !credentialsVisible }) { + Icon( + imageVector = if (credentialsVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (credentialsVisible) "Hide credentials" else "Show credentials" + ) + } + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Save & Reconnect + Button( + onClick = { + scope.launch { + ds.setAirBridgeRelayUrl(relayUrl) + ds.setAirBridgePairingId(pairingId) + ds.setAirBridgeSecret(secret) + AirBridgeClient.disconnect() + kotlinx.coroutines.delay(500) + AirBridgeClient.connect(context) + Toast.makeText(context, "Settings saved, reconnecting...", Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Save & Reconnect") + } + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index 54f03e3..dd24633 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -29,6 +30,8 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -43,6 +46,7 @@ import com.sameerasw.airsync.domain.model.ConnectedDevice import com.sameerasw.airsync.domain.model.UiState import com.sameerasw.airsync.presentation.ui.components.RotatingAppIcon import com.sameerasw.airsync.presentation.ui.components.SlowlyRotatingAppIcon +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @@ -137,26 +141,62 @@ fun ConnectionStatusCard( } } + val peerReallyActive by AirBridgeClient.peerReallyActive.collectAsState() + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - val ips = - uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } - ips.forEach { ip -> - val isActive = ip == uiState.activeIp + if (uiState.isRelayConnection) { + // When connected via relay only, show AirBridge indicator instead of LAN IPs Surface( shape = RoundedCornerShape(12.dp), - color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.tertiaryContainer, modifier = Modifier.animateContentSize() ) { - Text( - text = "$ip:${connectedDevice.port}", + Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_web_24), + contentDescription = "AirBridge", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Text( + text = "AirBridge", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + + // Peer health dot + Text( + text = "●", + style = MaterialTheme.typography.labelLarge, + color = if (peerReallyActive) Color(0xFF4CAF50) else Color(0xFFFF9800) + ) + } else { + val ips = + uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } + ips.forEach { ip -> + val isActive = ip == uiState.activeIp + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.animateContentSize() + ) { + Text( + text = "$ip:${connectedDevice.port}", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 7f21642..3b50cc3 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -32,6 +32,7 @@ fun LastConnectedDeviceCard( isAutoReconnectEnabled: Boolean, onToggleAutoReconnect: (Boolean) -> Unit, onQuickConnect: () -> Unit, + onConnectWithRelay: () -> Unit, ) { val haptics = LocalHapticFeedback.current @@ -121,23 +122,40 @@ fun LastConnectedDeviceCard( - Button( - onClick = { - HapticUtil.performClick(haptics) - onQuickConnect() - }, + Row( modifier = Modifier .fillMaxWidth() - .requiredHeight(65.dp) .padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), - contentDescription = "Quick connect", - modifier = Modifier.padding(end = 12.dp), -// tint = MaterialTheme.colorScheme.primary - ) - Text("Quick Connect") + Button( + onClick = { + HapticUtil.performClick(haptics) + onQuickConnect() + }, + modifier = Modifier + .weight(1f) + .requiredHeight(65.dp), + ) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), + contentDescription = "Quick connect", + modifier = Modifier.padding(end = 8.dp), + ) + Text("Quick Connect") + } + + Button( + onClick = { + HapticUtil.performClick(haptics) + onConnectWithRelay() + }, + modifier = Modifier + .weight(1f) + .requiredHeight(65.dp), + ) { + Text("Connect with Relay") + } } // Auto-reconnect toggle 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 06d5409..e86a138 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 @@ -114,6 +114,8 @@ import com.sameerasw.airsync.presentation.ui.components.sheets.HelpSupportBottom import com.sameerasw.airsync.presentation.ui.composables.WelcomeScreen import com.sameerasw.airsync.presentation.ui.models.AirSyncTab import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.ClipboardSyncManager import com.sameerasw.airsync.utils.HapticUtil import com.sameerasw.airsync.utils.JsonUtil @@ -447,19 +449,30 @@ fun AirSyncMainScreen( var pcName: String? = null var isPlus = false var symmetricKey: String? = null + var relayUrl: String? = null + var airBridgePairingId: String? = null + var airBridgeSecret: String? = null val queryPart = uri.toString().substringAfter('?', "") if (queryPart.isNotEmpty()) { - val params = queryPart.split('?') - val paramMap = params.associate { param -> - val parts = param.split('=', limit = 2) - val key = parts.getOrNull(0) ?: "" - val value = parts.getOrNull(1) ?: "" - key to value - } - pcName = paramMap["name"]?.let { URLDecoder.decode(it, "UTF-8") } + val paramMap = queryPart.split('?') + .mapNotNull { raw -> + if (raw.isBlank()) return@mapNotNull null + val parts = raw.split('=', limit = 2) + val key = parts.getOrNull(0)?.trim().orEmpty() + if (key.isEmpty()) return@mapNotNull null + key to (parts.getOrNull(1).orEmpty()) + } + .toMap() + + pcName = paramMap["name"]?.let { android.net.Uri.decode(it) } isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false - symmetricKey = paramMap["key"] + symmetricKey = paramMap["key"]?.let { android.net.Uri.decode(it) } + relayUrl = paramMap["relay"]?.let { android.net.Uri.decode(it) } + airBridgePairingId = + paramMap["pairingId"]?.let { android.net.Uri.decode(it) } + airBridgeSecret = + paramMap["secret"]?.let { android.net.Uri.decode(it) } } if (ip.isNotEmpty() && port.isNotEmpty()) { @@ -474,6 +487,36 @@ fun AirSyncMainScreen( // Trigger connection scope.launch { + // Save AirBridge credentials from QR when present. + if (!relayUrl.isNullOrBlank() && + !airBridgePairingId.isNullOrBlank() && + !airBridgeSecret.isNullOrBlank() + ) { + try { + val ds = DataStoreManager.getInstance(context) + ds.setAirBridgeRelayUrl(relayUrl!!) + ds.setAirBridgePairingId(airBridgePairingId!!) + ds.setAirBridgeSecret(airBridgeSecret!!) + ds.setAirBridgeEnabled(true) + + // Supply the symmetric key from the QR code + // so AirBridge can encrypt/decrypt immediately, + // even before a LAN connection saves the key. + if (!symmetricKey.isNullOrEmpty()) { + AirBridgeClient.updateSymmetricKey(symmetricKey) + } + + AirBridgeClient.disconnect() + AirBridgeClient.connect(context) + } catch (e: Exception) { + Log.e( + "AirSyncMainScreen", + "Failed to apply AirBridge QR config: ${e.message}", + e + ) + } + } + delay(300) // Brief delay to ensure UI updates connect() } @@ -827,6 +870,44 @@ fun AirSyncMainScreen( viewModel.updateSymmetricKey(device.symmetricKey) connect() } + }, + onConnectWithRelay = { + scope.launch { + try { + val ds = DataStoreManager.getInstance(context) + val relayUrl = ds.getAirBridgeRelayUrl().first() + val pairingId = ds.getAirBridgePairingId().first() + val secret = ds.getAirBridgeSecret().first() + + if (relayUrl.isBlank() || + pairingId.isBlank() || + secret.isBlank() + ) { + Toast.makeText( + context, + "AirBridge credentials are missing. Please scan a QR code with AirBridge info to use relay connection.", + Toast.LENGTH_LONG + ).show() + return@launch + } + + ds.setAirBridgeEnabled(true) + AirBridgeClient.disconnect() + AirBridgeClient.connect(context) + + Toast.makeText( + context, + "Attempting to connect via relay. This may take a moment...", + Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Toast.makeText( + context, + "Failed to connect via relay: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + } } ) } 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 f6be08c..61b434a 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 @@ -18,6 +18,7 @@ import com.sameerasw.airsync.domain.repository.AirSyncRepository import com.sameerasw.airsync.smartspacer.AirSyncDeviceTarget import com.sameerasw.airsync.utils.DeviceInfoUtil import com.sameerasw.airsync.utils.DiscoveredDevice +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.MacDeviceStatusManager import com.sameerasw.airsync.utils.NetworkMonitor import com.sameerasw.airsync.utils.PermissionUtil @@ -71,6 +72,9 @@ class AirSyncViewModel( // Network monitoring private var isNetworkMonitoringActive = false private var previousNetworkIp: String? = null + private var lastWebSocketConnected = false + private var lastRelayState: AirBridgeClient.State = AirBridgeClient.State.DISCONNECTED + private var lastUnifiedConnected = false private var appContext: Context? = null @@ -93,19 +97,37 @@ class AirSyncViewModel( // Connection status listener for WebSocket updates private val connectionStatusListener: (Boolean) -> Unit = { isConnected -> + lastWebSocketConnected = isConnected + updateUnifiedConnectionState() + } + + private fun updateUnifiedConnectionState() { viewModelScope.launch { + val relayConnected = lastRelayState == AirBridgeClient.State.RELAY_ACTIVE + val relayConnecting = lastRelayState == AirBridgeClient.State.CONNECTING || + lastRelayState == AirBridgeClient.State.REGISTERING || + lastRelayState == AirBridgeClient.State.WAITING_FOR_PEER + val unifiedConnected = lastWebSocketConnected || relayConnected + val unifiedConnecting = !unifiedConnected && (WebSocketUtil.isConnecting() || relayConnecting) + _uiState.value = _uiState.value.copy( - isConnected = isConnected, - isConnecting = false, - response = if (isConnected) "Connected successfully!" else "Disconnected", - activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null, - macDeviceStatus = if (isConnected) _uiState.value.macDeviceStatus else null + isConnected = unifiedConnected, + isConnecting = unifiedConnecting, + isRelayConnection = relayConnected && !lastWebSocketConnected, + response = when { + unifiedConnected -> "Connected successfully!" + unifiedConnecting -> "Connecting..." + else -> "Disconnected" + }, + activeIp = if (lastWebSocketConnected) WebSocketUtil.currentIpAddress else null, + macDeviceStatus = if (unifiedConnected) _uiState.value.macDeviceStatus else null ) - if (isConnected) { + if (unifiedConnected && !lastUnifiedConnected) { repository.setFirstMacConnectionTime(System.currentTimeMillis()) updateRatingPromptDisplay() } + lastUnifiedConnected = unifiedConnected // Notify Smartspacer of connection status change appContext?.let { context -> @@ -125,6 +147,12 @@ class AirSyncViewModel( WebSocketUtil.registerManualConnectListener(manualConnectCanceler) } catch (_: Exception) { } + viewModelScope.launch { + AirBridgeClient.connectionState.collect { relayState -> + lastRelayState = relayState + updateUnifiedConnectionState() + } + } // Observe Mac device status updates viewModelScope.launch { @@ -310,7 +338,14 @@ class AirSyncViewModel( val isNotificationEnabled = PermissionUtil.isNotificationListenerEnabled(context) // Check current WebSocket connection status - val currentlyConnected = WebSocketUtil.isConnected() + lastWebSocketConnected = WebSocketUtil.isConnected() + lastRelayState = AirBridgeClient.connectionState.value + val relayConnected = lastRelayState == AirBridgeClient.State.RELAY_ACTIVE + val relayConnecting = lastRelayState == AirBridgeClient.State.CONNECTING || + lastRelayState == AirBridgeClient.State.REGISTERING || + lastRelayState == AirBridgeClient.State.WAITING_FOR_PEER + val currentlyConnected = lastWebSocketConnected || relayConnected + val currentlyConnecting = !currentlyConnected && (WebSocketUtil.isConnecting() || relayConnecting) _uiState.value = _uiState.value.copy( ipAddress = savedIp, @@ -326,6 +361,7 @@ class AirSyncViewModel( isClipboardSyncEnabled = isClipboardSyncEnabled, isAutoReconnectEnabled = isAutoReconnectEnabled, isConnected = currentlyConnected, + isConnecting = currentlyConnecting, symmetricKey = symmetricKey ?: lastConnectedSymmetricKey, isContinueBrowsingEnabled = isContinueBrowsingEnabled, isSendNowPlayingEnabled = isSendNowPlayingEnabled, @@ -342,6 +378,18 @@ class AirSyncViewModel( isOnboardingCompleted = !isFirstRun, isQuickShareEnabled = isQuickShareEnabled ) + lastUnifiedConnected = currentlyConnected + + // If app was restarted and relay is already active, immediately bootstrap + // LAN-first probing instead of waiting for a future network callback. + if (relayConnected && !lastWebSocketConnected) { + WebSocketUtil.startLanFirstRelayProbe( + context = context, + immediate = true, + source = "viewmodel_initialize_state", + resetBackoff = true + ) + } updateRatingPromptDisplay() @@ -652,6 +700,8 @@ class AirSyncViewModel( repository.setQuickShareEnabled(enabled) val intent = Intent(context, com.sameerasw.airsync.quickshare.QuickShareService::class.java) if (enabled) { + // Start QuickShareService in foreground discovery mode so it can immediately call startForeground(). + intent.action = com.sameerasw.airsync.quickshare.QuickShareService.ACTION_START_DISCOVERY if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { @@ -750,18 +800,26 @@ class AirSyncViewModel( val target = hasNetworkAwareMappingForLastDevice() if (currentIp == "No Wi-Fi" || currentIp == "Unknown") { - // No usable Wi‑Fi: ensure we stop any active connection and do not attempt reconnect - try { - WebSocketUtil.disconnect(context) - } catch (_: Exception) { - } - // Stop service when no WiFi - try { - com.sameerasw.airsync.service.AirSyncService.stop(context) - } catch (_: Exception) { + // No usable Wi‑Fi. If relay is active, keep service/relay alive. + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + Log.d( + "AirSyncViewModel", + "Wi-Fi unavailable but relay is active; keeping relay path alive" + ) + _uiState.value = _uiState.value.copy(isConnecting = false) + } else { + // No LAN and no relay: stop active LAN path and service. + try { + WebSocketUtil.disconnect(context) + } catch (_: Exception) { + } + try { + com.sameerasw.airsync.service.AirSyncService.stop(context) + } catch (_: Exception) { + } + _uiState.value = + _uiState.value.copy(isConnected = false, isConnecting = false) } - _uiState.value = - _uiState.value.copy(isConnected = false, isConnecting = false) return@collect } else { // Ensure service is running when WiFi is available @@ -846,7 +904,11 @@ class AirSyncViewModel( } if (autoOn && !manual) { try { - WebSocketUtil.requestAutoReconnect(context) + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + WebSocketUtil.requestLanReconnectFromRelay(context) + } else { + WebSocketUtil.requestAutoReconnect(context) + } } catch (_: Exception) { } } diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 002e36a..04a5e2a 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -17,6 +17,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import com.sameerasw.airsync.MainActivity import com.sameerasw.airsync.R +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.DiscoveryMode import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil @@ -106,6 +107,18 @@ class AirSyncService : Service() { UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.ACTIVE) startForeground(NOTIFICATION_ID, buildNotification()) // Update notification if needed } + if (!WebSocketUtil.isConnected() && AirBridgeClient.isRelayConnectedOrConnecting()) { + WebSocketUtil.sendTransportOffer( + context = applicationContext, + reason = "app_foreground" + ) + WebSocketUtil.startLanFirstRelayProbe( + context = applicationContext, + immediate = true, + source = "app_foreground", + resetBackoff = true + ) + } } private fun handleAppBackground() { @@ -158,6 +171,35 @@ class AirSyncService : Service() { if (isScanning) { UDPDiscoveryManager.burstBroadcast(applicationContext) WebSocketUtil.requestAutoReconnect(applicationContext) + // If relay is already active, also force a direct LAN retry immediately. + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + WebSocketUtil.sendTransportOffer( + context = applicationContext, + reason = "network_onAvailable_scanning" + ) + WebSocketUtil.startLanFirstRelayProbe( + context = applicationContext, + immediate = true, + source = "network_onAvailable_scanning", + resetBackoff = true + ) + } + } + // When WiFi returns while relay is active but LAN is down, + // attempt to re-establish the preferred local connection. + if (!isScanning && !WebSocketUtil.isConnected() && AirBridgeClient.isRelayActive()) { + Log.i(TAG, "WiFi available while relay is active — attempting LAN reconnect") + UDPDiscoveryManager.burstBroadcast(applicationContext) + WebSocketUtil.sendTransportOffer( + context = applicationContext, + reason = "network_onAvailable_sync" + ) + WebSocketUtil.startLanFirstRelayProbe( + context = applicationContext, + immediate = true, + source = "network_onAvailable_sync", + resetBackoff = true + ) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt index 493d632..e34fc0d 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt @@ -98,7 +98,10 @@ class WakeupService : Service() { private suspend fun startHttpServer() { withContext(Dispatchers.IO) { try { - httpServerSocket = ServerSocket(HTTP_PORT) + httpServerSocket = ServerSocket().apply { + reuseAddress = true + bind(java.net.InetSocketAddress(HTTP_PORT)) + } serviceScope.launch { while (isRunning && httpServerSocket?.isClosed == false) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt new file mode 100644 index 0000000..b1f89a7 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -0,0 +1,655 @@ +package com.sameerasw.airsync.utils + +import android.content.Context +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import javax.crypto.Mac +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * Singleton that manages the WebSocket connection to the AirBridge relay server. + * Runs alongside the local WebSocket connection as a fallback for remote communication. + * + * Uses HMAC-SHA256 challenge-response authentication: + * 1. Server sends a challenge with a random nonce + * 2. Client responds with register containing HMAC(K, nonce|pairingId|role) where K = SHA256(secret_raw) + */ +object AirBridgeClient { + private const val TAG = "AirBridgeClient" + + // Connection state + enum class State { + DISCONNECTED, + CONNECTING, + CHALLENGE_RECEIVED, // Received nonce, computing HMAC + REGISTERING, + WAITING_FOR_PEER, + RELAY_ACTIVE, + FAILED + } + + // Connection state mutable state flow + private val _connectionState = MutableStateFlow(State.DISCONNECTED) + + val connectionState: StateFlow = _connectionState + + // WebSocket connection + private var webSocket: WebSocket? = null + private var client: OkHttpClient? = null + + // Symmetric key for relay encryption/decryption + private var symmetricKey: SecretKey? = null + private var appContext: Context? = null + + // Manual disconnect flag + private val isManuallyDisconnected = AtomicBoolean(false) + + // Reconnect backoff + private var reconnectJob: Job? = null + private var statusQueryJob: Job? = null + + // Number of consecutive reconnect attempts, used for exponential backoff calculation. + private var reconnectAttempt = 0 + + // Maximum backoff delay of 30 seconds to prevent excessively long waits. + private val maxReconnectDelay = 30_000L // 30 seconds + + // Prevent concurrent connect attempts to + private val connectInProgress = AtomicBoolean(false) + private val activeConnectionGeneration = AtomicLong(0L) + + // Cached view of last status_reply from relay + @Volatile + private var lastStatusBothConnected: Boolean = false + private val _peerReallyActive = MutableStateFlow(false) + val peerReallyActive: StateFlow = _peerReallyActive + + // Message callback — routes relayed messages to the existing handler + private var onMessageReceived: ((Context, String) -> Unit)? = null + + // Updates the connection state and logs the transition reason. + private fun setState(newState: State, _reason: String) { + _connectionState.value = newState + if (newState != State.RELAY_ACTIVE) { + _peerReallyActive.value = false + } + } + + /** + * Connects to the AirBridge relay server. + * Reads configuration from DataStore. + */ + fun connect(context: Context) { + appContext = context.applicationContext + isManuallyDisconnected.set(false) + + // Guard against duplicate connect attempts while a usable relay connection is already active/in-flight. + if (isRelayConnectedOrConnecting()) { + return + } + + if (!connectInProgress.compareAndSet(false, true)) { + return + } + + // Read config and connect in background to avoid blocking the caller and allow suspend functions. + CoroutineScope(Dispatchers.IO).launch { + try { + // First check if AirBridge is enabled and config is present before attempting connection. + val ds = DataStoreManager.getInstance(context) + // If disabled, set state and exit early to avoid unnecessary connection attempts and log spam. + val enabled = ds.getAirBridgeEnabled().first() + // If AirBridge is disabled, set state to DISCONNECTED and return early to prevent connection attempts and log spam. + if (!enabled) { + setState(State.DISCONNECTED, "AirBridge disabled in settings") + return@launch + } + val relayUrl = ds.getAirBridgeRelayUrl().first() + val pairingId = ds.getAirBridgePairingId().first() + val secretRaw = ds.getAirBridgeSecret().first() + + // Validate config values before connecting to prevent futile attempts and log spam. + if (relayUrl.isBlank()) { + Log.w(TAG, "Relay URL is empty, skipping connection") + setState(State.DISCONNECTED, "Missing relay URL") + return@launch + } + + // Pairing ID and secret are required for registration, so treat missing values as failed state to prompt user action. + if (pairingId.isBlank() || secretRaw.isBlank()) { + Log.w(TAG, "Pairing ID or secret is empty, skipping connection") + setState(State.FAILED, "Missing pairing credentials") + return@launch + } + + // Load/refresh symmetric key for relay encryption/decryption. + // Fallback to network-aware records if lastConnected is empty/stale. + if (symmetricKey == null) { + symmetricKey = resolveSymmetricKey(ds) + } + + if (symmetricKey == null) { + Log.e(TAG, "SECURITY: No symmetric key resolved — refusing relay connection to prevent plaintext transport") + setState(State.FAILED, "No encryption key available") + return@launch + } + + // Pass raw secret — HMAC computation happens after receiving challenge + connectInternal(relayUrl, pairingId, secretRaw) + } catch (e: Exception) { + Log.e(TAG, "Failed to read AirBridge config") + setState(State.FAILED, "Failed reading persisted config") + } finally { + connectInProgress.set(false) + } + } + } + + /** + * Allows LAN flow to explicitly refresh the relay key, so transport switching is seamless. + */ + fun updateSymmetricKey(base64Key: String?) { + symmetricKey = base64Key?.let { CryptoUtil.decodeKey(it) } + } + + // Resolves the symmetric key for relay encryption/decryption. + private suspend fun resolveSymmetricKey(ds: DataStoreManager): SecretKey? { + // First try to get the most recently connected device's key, which is the most likely to be valid. + val fromLast = ds.getLastConnectedDevice().first()?.symmetricKey + ?.let { CryptoUtil.decodeKey(it) } + // If that fails, look through all known devices for the most recently connected one with a valid key. + if (fromLast != null) return fromLast + + // Only consider devices that have connected at least once (lastConnected > 0) to avoid using stale keys from old records that were never active. + val fromNetwork = ds.getAllNetworkDeviceConnections().first() + .sortedByDescending { it.lastConnected } + .firstNotNullOfOrNull { conn -> + conn.symmetricKey?.let { CryptoUtil.decodeKey(it) } + } + return fromNetwork + } + + /** + * Ensures relay connection is active when enabled. + * If immediate=true, cancels pending backoff reconnect and retries now. + */ + fun ensureConnected(context: Context, immediate: Boolean = false) { + // Create a new coroutine scope for this operation to avoid blocking the caller and allow suspend functions. + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = DataStoreManager.getInstance(context) + val enabled = ds.getAirBridgeEnabled().first() + if (!enabled) { + return@launch + } + // If already connected or in the process of connecting, do nothing. Otherwise, attempt to connect. + when (_connectionState.value) { + State.RELAY_ACTIVE, + State.WAITING_FOR_PEER, + State.REGISTERING, + State.CHALLENGE_RECEIVED, + State.CONNECTING -> { + // Already connected/connecting + return@launch + } + else -> { + if (immediate) { + reconnectJob?.cancel() + reconnectJob = null + reconnectAttempt = 0 + } + connect(context) + } + } + } catch (e: Exception) { + Log.e(TAG, "ensureConnected failed") + } + } + } + + /** + * Disconnects from the relay server. Disables auto-reconnect. + */ + fun disconnect() { + isManuallyDisconnected.set(true) + reconnectJob?.cancel() + reconnectJob = null + WebSocketUtil.stopLanFirstRelayProbe("relay_manual_disconnect") + statusQueryJob?.cancel() + statusQueryJob = null + reconnectAttempt = 0 + lastStatusBothConnected = false + _peerReallyActive.value = false + + // Close WebSocket connection gracefully. + try { + webSocket?.close(1000, "Manual disconnect") + } catch (_: Exception) {} + webSocket = null + activeConnectionGeneration.incrementAndGet() + + client?.dispatcher?.executorService?.shutdown() + client = null + + setState(State.DISCONNECTED, "Manual disconnect") + } + + /** + * Sends a pre-encrypted message through the relay. + * @return true if the message was enqueued successfully. + */ + fun sendMessage(message: String): Boolean { + // Only allow sending if we have an active WebSocket connection and are in a state that should allow relay messages. + val ws = webSocket ?: return false + // Only allow sending relay messages if we're in a state where the relay should be active or soon-to-be-active. + if (_connectionState.value != State.RELAY_ACTIVE && + _connectionState.value != State.WAITING_FOR_PEER) { + return false + } + + // Encrypt with the same symmetric key used for local connections. + val key = symmetricKey + if (key == null) { + Log.e(TAG, "SECURITY: Cannot send relay message — no symmetric key available. Dropping message to prevent plaintext leak.") + return false + } + + // Encrypt the message for relay transport. If encryption fails, drop the message to prevent plaintext leak. + val messageToSend = CryptoUtil.encryptMessage(message, key) + if (messageToSend == null) { + Log.e(TAG, "SECURITY: Encryption failed, dropping message to prevent plaintext leak.") + return false + } + + return try { + ws.send(messageToSend) + } catch (e: Exception) { + Log.e(TAG, "Failed to send relay message") + false + } + } + + /** + * Returns true if the relay is active and ready to forward messages. + */ + fun isRelayActive(): Boolean = _connectionState.value == State.RELAY_ACTIVE + + /** + * Returns true if relay transport is already usable or being established. + * Useful to suppress noisy LAN reconnect loops while relay failover is in progress. + */ + fun isRelayConnectedOrConnecting(): Boolean { + return when (_connectionState.value) { + State.RELAY_ACTIVE, + State.WAITING_FOR_PEER, + State.REGISTERING, + State.CHALLENGE_RECEIVED, + State.CONNECTING -> true + else -> false + } + } + + /** + * Sets the message handler. Called once during app initialization. + */ + fun setMessageHandler(handler: (Context, String) -> Unit) { + onMessageReceived = handler + } + + // MARK: - HMAC Computation + + /** + * Computes the HMAC-SHA256 signature and kInit for challenge-response auth. + * + * @param secretRaw The plain-text secret from the QR code + * @param nonce The server-provided nonce from the challenge message + * @param pairingId The pairing ID + * @return Pair of (sig, kInit) both hex-encoded + */ + private fun computeHmac(secretRaw: String, nonce: String, pairingId: String): Pair { + // K = SHA256(secret_raw) as raw bytes + val kBytes = MessageDigest.getInstance("SHA-256").digest(secretRaw.toByteArray(Charsets.UTF_8)) + + // kInit = hex(K) — sent only for session bootstrap + val kInit = kBytes.joinToString("") { "%02x".format(it) } + + // message = nonce|pairingId|role + val role = "android" + val message = "$nonce|$pairingId|$role" + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(kBytes, "HmacSHA256")) + val sig = mac.doFinal(message.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + + return Pair(sig, kInit) + } + + /** + * Internal function to establish WebSocket connection and handle relay protocol. + * Uses HMAC challenge-response: waits for challenge, then sends register with HMAC sig. + */ + private fun connectInternal(relayUrl: String, pairingId: String, secretRaw: String) { + // Allocate a fresh generation so callbacks from older sockets are ignored. + val generation = activeConnectionGeneration.incrementAndGet() + + // Set state early to prevent concurrent connect attempts and log spam while connection is in progress. + setState(State.CONNECTING, "Opening websocket to relay") + + // Normalize the relay URL to ensure it has the correct scheme and path. This also enforces ws:// for private hosts and wss:// for public hosts to prevent user misconfiguration that could lead to plaintext transport over the internet. + val normalizedUrl = normalizeRelayUrl(relayUrl) + // Lazily initialize OkHttpClient with timeouts suitable for a long-lived relay connection. + if (client == null) { + client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .pingInterval(0, TimeUnit.SECONDS) // Disable client-side pings to prevent protocol conflicts + .build() + } + + // Build the WebSocket request + val request = Request.Builder() + .url(normalizedUrl) + .build() + + // Create a new WebSocket connection with a listener to handle the relay protocol. + val listener = object : WebSocketListener() { + fun isStale(): Boolean = generation != activeConnectionGeneration.get() + + // On open, wait for the server's challenge (do NOT send register immediately) + override fun onOpen(webSocket: WebSocket, response: Response) { + if (isStale()) { + try { + webSocket.close(1000, "Stale relay socket") + } catch (_: Exception) {} + return + } + this@AirBridgeClient.webSocket = webSocket + setState(State.CONNECTING, "Socket open, waiting for challenge") + reconnectAttempt = 0 + } + + override fun onMessage(webSocket: WebSocket, text: String) { + if (isStale()) return + handleTextMessage(text, webSocket, pairingId, secretRaw) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + if (isStale()) return + webSocket.close(1000, null) + setState(State.DISCONNECTED, "Socket closing code=$code") + WebSocketUtil.stopLanFirstRelayProbe("relay_onClosing") + statusQueryJob?.cancel() + statusQueryJob = null + lastStatusBothConnected = false + _peerReallyActive.value = false + if (this@AirBridgeClient.webSocket == webSocket) { + this@AirBridgeClient.webSocket = null + } + scheduleReconnect(relayUrl, pairingId, secretRaw, generation) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (isStale()) return + val msg = if (t is java.io.EOFException) "Server closed connection (EOF)" else (t.message ?: "Unknown error ($t)") + Log.e(TAG, "Relay connection failed") + setState(State.FAILED, "Socket failure: $msg") + WebSocketUtil.stopLanFirstRelayProbe("relay_onFailure") + statusQueryJob?.cancel() + statusQueryJob = null + lastStatusBothConnected = false + _peerReallyActive.value = false + if (this@AirBridgeClient.webSocket == webSocket) { + this@AirBridgeClient.webSocket = null + } + scheduleReconnect(relayUrl, pairingId, secretRaw, generation) + } + } + + client!!.newWebSocket(request, listener) + } + + private fun handleTextMessage(text: String, ws: WebSocket, pairingId: String, secretRaw: String) { + // First, try to parse as an AirBridge control message + try { + val json = JSONObject(text) + val action = json.optString("action", "") + + when (action) { + "challenge" -> { + // Server sent challenge — compute HMAC and respond with register + val nonce = json.optString("nonce", "") + if (nonce.isBlank()) { + Log.e(TAG, "Challenge received but nonce is empty") + setState(State.FAILED, "Invalid challenge from server") + return + } + + setState(State.CHALLENGE_RECEIVED, "Computing HMAC signature") + + val (sig, kInit) = computeHmac(secretRaw, nonce, pairingId) + + // Send register with HMAC signature + val regMsg = JSONObject().apply { + put("action", "register") + put("role", "android") + put("pairingId", pairingId) + put("sig", sig) + put("kInit", kInit) + put("localIp", DeviceInfoUtil.getWifiIpAddress(appContext!!) ?: "unknown") + put("port", 0) // Android doesn't run a server, it's the client + } + + setState(State.REGISTERING, "Sending register with HMAC") + if (ws.send(regMsg.toString())) { + setState(State.WAITING_FOR_PEER, "Registration accepted, waiting peer") + // Reset status cache on fresh registration + lastStatusBothConnected = false + _peerReallyActive.value = false + } else { + Log.e(TAG, "Failed to send registration") + setState(State.FAILED, "Registration send failed") + } + return + } + "relay_started" -> { + setState(State.RELAY_ACTIVE, "Server confirmed relay tunnel") + reconnectJob?.cancel() + reconnectJob = null + reconnectAttempt = 0 + startStatusPolling() + // Advertise effective transport immediately so desktop UI can switch + // icon/actions without waiting for stale local session cleanup. + val transport = if (WebSocketUtil.isConnected()) "wifi" else "relay" + WebSocketUtil.notifyPeerTransportChanged(transport, force = true) + appContext?.let { ctx -> + if (!WebSocketUtil.isConnected()) { + val transportGeneration = WebSocketUtil.nextTransportGeneration() + WebSocketUtil.sendTransportOffer( + context = ctx, + reason = "relay_started", + generation = transportGeneration + ) + WebSocketUtil.startLanFirstRelayProbe( + context = ctx, + immediate = true, + source = "relay_started", + resetBackoff = true + ) + } + } + + // Trigger initial sync via relay now that the tunnel is active + appContext?.let { ctx -> + CoroutineScope(Dispatchers.IO).launch { + try { + delay(1000) // Stabilize connection before sending data + SyncManager.performInitialSync(ctx) + } catch (e: Exception) { + Log.e(TAG, "Failed to perform initial sync via relay") + } + } + } + return + } + "status_reply" -> { + // Health snapshot from relay: record whether both peers are currently attached. + val both = json.optBoolean("bothConnected", false) + val macActive = json.optBoolean("macActive", false) + val androidActive = json.optBoolean("androidActive", false) + lastStatusBothConnected = both && macActive && androidActive + _peerReallyActive.value = isRelayActive() && lastStatusBothConnected + return + } + "mac_info" -> { + // Server echoing Mac's info — we can ignore this at the relay level + // but let the message handler process it for device discovery + } + "error" -> { + val msg = json.optString("message", "Unknown error") + Log.e(TAG, "Relay server error") + setState(State.FAILED, "Server error action: $msg") + return + } + } + } catch (_: Exception) { + // Not a JSON control message, treat as relayed payload + } + + // Decrypt and forward to the existing message handler. + // Try to decrypt first, but allow plaintext fallback for resilience + val key = symmetricKey + var processedMessage: String? = null + + if (key != null) { + processedMessage = CryptoUtil.decryptMessage(text, key) + } + + if (processedMessage == null) { + // Decryption failed or no key available. + // Check if the raw message looks like valid JSON (plaintext fallback). + if (text.trim().startsWith("{")) { + Log.w(TAG, "Decryption failed (or no key), falling back to plaintext processing") + processedMessage = text + } else { + Log.e(TAG, "SECURITY: Decryption failed and message is not JSON. Dropping.") + return + } + } + + appContext?.let { ctx -> + onMessageReceived?.invoke(ctx, processedMessage) + } + } + + // Schedules a reconnect attempt with exponential backoff. Resets the backoff if the connection is successful. Does nothing if the disconnect was manual. + private fun scheduleReconnect(relayUrl: String, pairingId: String, secretRaw: String, sourceGeneration: Long) { + if (isManuallyDisconnected.get()) return + if (sourceGeneration != activeConnectionGeneration.get()) return + + val delayMs = minOf( + (1L shl minOf(reconnectAttempt, 10)) * 1000L, + maxReconnectDelay + ) + reconnectAttempt++ + + setState(State.CONNECTING, "Backoff reconnect scheduled in ${delayMs}ms") + + reconnectJob?.cancel() + reconnectJob = CoroutineScope(Dispatchers.IO).launch { + delay(delayMs) + if (!isManuallyDisconnected.get() && sourceGeneration == activeConnectionGeneration.get()) { + connectInternal(relayUrl, pairingId, secretRaw) + } + } + } + + // Periodically asks relay for session health so UI can detect relay-zombie situations. + private fun startStatusPolling() { + statusQueryJob?.cancel() + statusQueryJob = CoroutineScope(Dispatchers.IO).launch { + while (isRelayActive() && !isManuallyDisconnected.get()) { + sendStatusQuery() + delay(5_000) + } + } + } + + private fun sendStatusQuery() { + val ws = webSocket ?: return + val msg = JSONObject().apply { + put("action", "query_status") + }.toString() + try { + ws.send(msg) + } catch (e: Exception) { + } + } + + /** + * Normalizes the relay URL: adds ws:// or wss:// prefix and /ws suffix. + * Uses ws:// for localhost/private IPs, wss:// for remote domains. + */ + private fun normalizeRelayUrl(raw: String): String { + var url = raw.trim() + + // Extract host for private-IP detection + val host: String = run { + var h = url + if (h.startsWith("wss://")) h = h.removePrefix("wss://") + else if (h.startsWith("ws://")) h = h.removePrefix("ws://") + h.split(":").firstOrNull()?.split("/")?.firstOrNull() ?: "" + } + + val isPrivate = isPrivateHost(host) + + // If user explicitly provided ws://, only allow it for private/localhost hosts. + // Upgrade to wss:// for public hosts to prevent cleartext transport over the internet. + if (url.startsWith("ws://") && !url.startsWith("wss://") && !isPrivate) { + Log.w(TAG, "SECURITY: Upgrading ws:// to wss:// for public host") + url = "wss://" + url.removePrefix("ws://") + } + + // Add scheme if missing + if (!url.startsWith("ws://") && !url.startsWith("wss://")) { + url = if (isPrivate) "ws://$url" else "wss://$url" + } + + // Add /ws path if missing + if (!url.endsWith("/ws")) { + url = if (url.endsWith("/")) "${url}ws" else "$url/ws" + } + + return url + } + + /** Returns true if the host is loopback or RFC 1918 private address. */ + private fun isPrivateHost(host: String): Boolean { + if (host == "localhost" || host == "127.0.0.1" || host == "::1") return true + if (host.startsWith("192.168.") || host.startsWith("10.")) return true + // RFC 1918: only 172.16.0.0 – 172.31.255.255 (NOT all of 172.*) + if (host.startsWith("172.")) { + val second = host.split(".").getOrNull(1)?.toIntOrNull() + if (second != null && second in 16..31) return true + } + return false + } +} 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 12dca51..292f23f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlin.math.abs import org.json.JSONObject /** @@ -22,6 +23,20 @@ import org.json.JSONObject */ object WebSocketMessageHandler { private const val TAG = "WebSocketMessageHandler" + private const val TRANSPORT_CANDIDATE_TTL_MS = 120_000L + + private data class CandidateExtractionResult( + val ipsCsv: String, + val port: Int, + val total: Int, + val emptyIp: Int, + val nonPrivateIp: Int, + val invalidPort: Int + ) { + fun invalidReason(): String { + return "accepted_ips=${ipsCsv.split(",").filter { it.isNotBlank() }.size} total=$total empty_ip=$emptyIp non_private_ip=$nonPrivateIp invalid_port=$invalidPort port=$port" + } + } // Track if we're currently receiving playing media from Mac to prevent feedback loop private var isReceivingPlayingMedia = false @@ -83,6 +98,13 @@ object WebSocketMessageHandler { "modifierStatus" -> handleModifierStatus(data) "ping" -> handlePing(context) "status" -> handleMacDeviceStatus(context, data) + "macWake" -> handleMacWake(context, data) + "peerTransport" -> handlePeerTransport(data) + "transportOffer" -> handleTransportOffer(context, data) + "transportAnswer" -> handleTransportAnswer(context, data) + "transportCheck" -> handleTransportCheck(data) + "transportCheckAck" -> handleTransportCheckAck(data) + "transportNominate" -> handleTransportNominate(data) "macInfo" -> handleMacInfo(context, data) "refreshAdbPorts" -> handleRefreshAdbPorts(context) "browseLs" -> handleBrowseLs(context, data) @@ -394,14 +416,278 @@ 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 - SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) + // Respond with lightweight pong for keepalive (works for both LAN and Relay) + CoroutineScope(Dispatchers.IO).launch { + WebSocketUtil.sendMessage("{\"type\":\"pong\"}") + } } catch (e: Exception) { Log.e(TAG, "Error handling ping: ${e.message}") } } + private fun handleMacWake(context: Context, data: JSONObject?) { + try { + val ips = data?.optString("ips", "") ?: "" + val port = data?.optInt("port", -1) ?: -1 + val adapter = data?.optString("adapter", "auto") ?: "auto" + + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = DataStoreManager.getInstance(context) + val last = ds.getLastConnectedDevice().first() + val key = last?.symmetricKey + + if (!WebSocketUtil.isConnected() && !WebSocketUtil.isConnecting()) { + if (ips.isNotBlank() && port > 0 && key != null) { + WebSocketUtil.connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = key, + manualAttempt = false + ) + delay(1200) + } else { + try { + UDPDiscoveryManager.burstBroadcast(context) + } catch (_: Exception) {} + } + } + + // Keep probing LAN while relay remains active (LAN-first dynamic policy). + WebSocketUtil.startLanFirstRelayProbe( + context = context, + immediate = false, + source = "macWake", + resetBackoff = true + ) + } catch (e: Exception) { + Log.e(TAG, "Error handling macWake LAN orchestration: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error handling macWake: ${e.message}") + } + } + + private fun handlePeerTransport(data: JSONObject?) { + try { + if (data == null) return + } catch (e: Exception) { + Log.e(TAG, "Error handling peerTransport: ${e.message}") + } + } + + private fun isTransportMessageFresh(data: JSONObject?): Boolean { + val ts = data?.optLong("ts", 0L) ?: 0L + if (ts <= 0L) return false + val delta = abs(System.currentTimeMillis() - ts) + return delta <= TRANSPORT_CANDIDATE_TTL_MS + } + + private fun isPrivateOrAllowedLocalIp(ip: String): Boolean { + if (ip == "127.0.0.1" || ip == "localhost") return true + if (ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("100.")) return true + if (!ip.startsWith("172.")) return false + val parts = ip.split(".") + if (parts.size < 2) return false + val second = parts[1].toIntOrNull() ?: return false + return second in 16..31 + } + + private fun extractSanitizedCandidates(data: JSONObject?): CandidateExtractionResult { + val candidates = data?.optJSONArray("candidates") + val ips = mutableListOf() + var port = -1 + var total = 0 + var emptyIp = 0 + var nonPrivateIp = 0 + var invalidPort = 0 + if (candidates != null) { + for (i in 0 until candidates.length()) { + total++ + val c = candidates.optJSONObject(i) ?: continue + val ip = c.optString("ip", "").trim() + val p = c.optInt("port", -1) + if (ip.isBlank()) { + emptyIp++ + continue + } + if (!isPrivateOrAllowedLocalIp(ip)) { + nonPrivateIp++ + continue + } + ips.add(ip) + if (p in 1..65535 && port <= 0) { + port = p + } else if (p !in 1..65535 && p != -1) { + invalidPort++ + } + } + } + val fallbackPort = data?.optInt("port", -1) ?: -1 + if (port <= 0 && fallbackPort in 1..65535) { + port = fallbackPort + } + return CandidateExtractionResult( + ipsCsv = ips.distinct().joinToString(","), + port = port, + total = total, + emptyIp = emptyIp, + nonPrivateIp = nonPrivateIp, + invalidPort = invalidPort + ) + } + + private fun handleTransportOffer(context: Context, data: JSONObject?) { + try { + val generation = data?.optLong("generation", 0L) ?: 0L + val source = data?.optString("source", "peer") ?: "peer" + if (!WebSocketUtil.isLanNegotiationAllowed(context)) { + return + } + + if (!isTransportMessageFresh(data)) { + Log.w(TAG, "Dropped stale transport offer") + return + } + if (!WebSocketUtil.acceptIncomingTransportGeneration(generation, "offer_rx")) { + return + } + + // Always answer so the remote peer can proceed with nomination logic. + WebSocketUtil.sendTransportAnswer(generation, reason = "offer_rx", context = context) + + // Android is the LAN dialer in this architecture; only react to Mac offers. + if (source != "mac") return + if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) return + + val candidateResult = extractSanitizedCandidates(data) + val ipsCsv = candidateResult.ipsCsv + val port = candidateResult.port + CoroutineScope(Dispatchers.IO).launch { + val ds = DataStoreManager.getInstance(context) + val key = ds.getLastConnectedDevice().first()?.symmetricKey + if (ipsCsv.isBlank() || port <= 0 || key.isNullOrBlank()) { + Log.w(TAG, "Dropped transport offer with invalid candidates") + WebSocketUtil.reportLanNegotiationFailure("offer_missing_candidates_or_key") + return@launch + } + + WebSocketUtil.connect( + context = context, + ipAddress = ipsCsv, + port = port, + symmetricKey = key, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + WebSocketUtil.sendTransportCheck(generation, "offer_connect_ok") + } else { + WebSocketUtil.reportLanNegotiationFailure("offer_connect_failed") + } + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error handling transportOffer: ${e.message}") + WebSocketUtil.reportLanNegotiationFailure("offer_exception") + } + } + + private fun handleTransportAnswer(context: Context, data: JSONObject?) { + try { + val generation = data?.optLong("generation", 0L) ?: 0L + if (!WebSocketUtil.isLanNegotiationAllowed(context)) { + return + } + + if (!isTransportMessageFresh(data)) { + Log.w(TAG, "Dropped stale transport answer") + return + } + if (!WebSocketUtil.acceptIncomingTransportGeneration(generation, "answer_rx")) { + return + } + + // If LAN is already up, no need to dial again. + if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) return + + // Reuse answer candidates as immediate dial hints (Happy Eyeballs LAN-first). + val candidateResult = extractSanitizedCandidates(data) + val ipsCsv = candidateResult.ipsCsv + val port = candidateResult.port + + CoroutineScope(Dispatchers.IO).launch { + val ds = DataStoreManager.getInstance(context) + val key = ds.getLastConnectedDevice().first()?.symmetricKey + if (ipsCsv.isBlank() || port <= 0 || key.isNullOrBlank()) { + Log.w(TAG, "Dropped transport answer with invalid candidates") + return@launch + } + + WebSocketUtil.connect( + context = context, + ipAddress = ipsCsv, + port = port, + symmetricKey = key, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + WebSocketUtil.sendTransportCheck(generation, "answer_connect_ok") + } else { + WebSocketUtil.reportLanNegotiationFailure("answer_connect_failed") + } + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error handling transportAnswer: ${e.message}") + } + } + + private fun handleTransportCheck(data: JSONObject?) { + try { + val generation = data?.optLong("generation", 0L) ?: 0L + val token = data?.optString("token", "") ?: "" + if (token.isBlank() || !WebSocketUtil.isTransportGenerationActive(generation)) return + WebSocketUtil.sendTransportCheckAck(generation, token) + } catch (e: Exception) { + Log.e(TAG, "Error handling transportCheck: ${e.message}") + } + } + + private fun handleTransportCheckAck(data: JSONObject?) { + try { + val generation = data?.optLong("generation", 0L) ?: 0L + val token = data?.optString("token", "") ?: "" + if (token.isBlank() || !WebSocketUtil.isTransportGenerationActive(generation)) return + WebSocketUtil.onTransportCheckAck(generation, token) + } catch (e: Exception) { + Log.e(TAG, "Error handling transportCheckAck: ${e.message}") + } + } + + private fun handleTransportNominate(data: JSONObject?) { + try { + val generation = data?.optLong("generation", 0L) ?: 0L + val path = data?.optString("path", "relay") ?: "relay" + if (!WebSocketUtil.isTransportGenerationActive(generation)) { + Log.w(TAG, "Dropped transport nominate for inactive generation") + return + } + if (path == "lan") { + if (!WebSocketUtil.isConnected() || !WebSocketUtil.isTransportGenerationValidated(generation)) { + Log.w(TAG, "Dropped LAN nominate before validation") + return + } + WebSocketUtil.reportLanNegotiationSuccess("peer_nominate_lan") + } + } catch (e: Exception) { + Log.e(TAG, "Error handling transportNominate: ${e.message}") + } + } + private fun handleDisconnectRequest(context: Context) { try { // Mark as intentional disconnect to prevent auto-reconnect 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 1e59e68..131a00e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -8,14 +8,23 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONObject +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import java.util.UUID /** * Singleton utility for managing the WebSocket connection to the AirSync Mac server. @@ -46,6 +55,31 @@ object WebSocketUtil { private var autoReconnectActive = AtomicBoolean(false) private var autoReconnectStartTime: Long = 0L private var autoReconnectAttempts: Int = 0 + private val lastRelayLanRetryMs = AtomicLong(0L) + private val lastAdvertisedTransport = java.util.concurrent.atomic.AtomicReference(null) + private var relayLanProbeJob: Job? = null + private val relayLanProbeStartedAtMs = AtomicLong(0L) + private val consecutiveLanProbeFailures = AtomicInteger(0) + private val lanProbeCooldownUntilMs = AtomicLong(0L) + private val lastLanProbeDiscoveryBurstMs = AtomicLong(0L) + private val transportGeneration = AtomicLong(0L) + private val activeTransportGeneration = AtomicLong(0L) + private val activeTransportGenerationStartedAtMs = AtomicLong(0L) + private val validatedTransportGeneration = AtomicLong(0L) + private val pendingTransportCheckGeneration = AtomicLong(0L) + private val pendingTransportCheckToken = AtomicReference(null) + private var transportCheckTimeoutJob: Job? = null + + private const val RELAY_LAN_PROBE_FAST_WINDOW_MS = 120_000L + private const val RELAY_LAN_PROBE_MEDIUM_WINDOW_MS = 10 * 60_000L + private const val RELAY_LAN_PROBE_FAST_INTERVAL_MS = 15_000L + private const val RELAY_LAN_PROBE_MEDIUM_INTERVAL_MS = 30_000L + private const val RELAY_LAN_PROBE_SLOW_INTERVAL_MS = 60_000L + private const val RELAY_LAN_PROBE_MAX_CONSECUTIVE_FAILURES = 8 + private const val RELAY_LAN_PROBE_COOLDOWN_MS = 5 * 60_000L + private const val RELAY_LAN_PROBE_DISCOVERY_MIN_INTERVAL_MS = 30_000L + private const val TRANSPORT_CHECK_TIMEOUT_MS = 6_000L + private const val TRANSPORT_GENERATION_TTL_MS = 120_000L // Callback for connection status changes private var onConnectionStatusChanged: ((Boolean) -> Unit)? = null @@ -57,6 +91,118 @@ object WebSocketUtil { // Global connection status listeners for UI updates private val connectionStatusListeners = mutableSetOf<(Boolean) -> Unit>() + // Advertises the current Android transport to peer so desktop UI can switch immediately. + fun notifyPeerTransportChanged(transport: String, force: Boolean = false): Boolean { + val previous = lastAdvertisedTransport.get() + if (!force && previous == transport) return true + + val payload = JSONObject().apply { + put("type", "peerTransport") + put("data", JSONObject().apply { + put("source", "android") + put("transport", transport) // "wifi" | "relay" + put("ts", System.currentTimeMillis()) + }) + }.toString() + + val sent = if (transport == "relay") { + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + AirBridgeClient.sendMessage(payload) + } else { + sendMessage(payload) + } + } else { + sendMessage(payload) + } + + if (sent) { + lastAdvertisedTransport.set(transport) + } + return sent + } + + /** + * Starts a LAN-first probe loop while relay is active. + * The loop keeps relay as fallback and periodically retries direct LAN recovery. + */ + fun startLanFirstRelayProbe( + context: Context, + immediate: Boolean = true, + source: String = "unknown", + resetBackoff: Boolean = false + ) { + appContext = context.applicationContext + + if (!isLanNegotiationAllowed(context)) { + stopLanFirstRelayProbe("no_lan_network") + return + } + + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + stopLanFirstRelayProbe("relay_not_active") + return + } + + val now = System.currentTimeMillis() + if (relayLanProbeJob?.isActive == true) { + if (resetBackoff) { + relayLanProbeStartedAtMs.set(now) + resetLanProbeFailureState("reset_by:$source") + } + if (immediate) { + CoroutineScope(Dispatchers.IO).launch { + requestLanReconnectFromRelay(context, source = "immediate:$source") + } + } + return + } + + relayLanProbeStartedAtMs.set(now) + if (resetBackoff) { + resetLanProbeFailureState("reset_by:$source") + } + relayLanProbeJob = CoroutineScope(Dispatchers.IO).launch { + if (immediate) { + requestLanReconnectFromRelay(context, source = "start:$source") + } + + while (isActive) { + val elapsed = (System.currentTimeMillis() - relayLanProbeStartedAtMs.get()).coerceAtLeast(0L) + val intervalMs = computeAdaptiveLanProbeInterval(elapsed) + val nowLoop = System.currentTimeMillis() + val cooldownUntil = lanProbeCooldownUntilMs.get() + if (cooldownUntil > nowLoop) { + val remaining = cooldownUntil - nowLoop + delay(minOf(remaining, intervalMs)) + continue + } + delay(intervalMs) + if (isConnected.get()) { + break + } + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + break + } + requestLanReconnectFromRelay(context, source = "periodic:$source") + } + } + } + + private fun computeAdaptiveLanProbeInterval(elapsedMs: Long): Long { + return when { + elapsedMs < RELAY_LAN_PROBE_FAST_WINDOW_MS -> RELAY_LAN_PROBE_FAST_INTERVAL_MS + elapsedMs < (RELAY_LAN_PROBE_FAST_WINDOW_MS + RELAY_LAN_PROBE_MEDIUM_WINDOW_MS) -> RELAY_LAN_PROBE_MEDIUM_INTERVAL_MS + else -> RELAY_LAN_PROBE_SLOW_INTERVAL_MS + } + } + + fun stopLanFirstRelayProbe(_reason: String = "unspecified") { + relayLanProbeJob?.cancel() + relayLanProbeJob = null + relayLanProbeStartedAtMs.set(0L) + } + + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) @@ -155,7 +301,25 @@ object WebSocketUtil { } currentIpAddress = ipAddress currentPort = port - currentSymmetricKey = symmetricKey?.let { CryptoUtil.decodeKey(it) } + currentSymmetricKey = try { + symmetricKey?.let { CryptoUtil.decodeKey(it) } + } catch (e: Exception) { + Log.e(TAG, "Failed to decode symmetric key: ${e.message}") + null + } + if (symmetricKey != null && currentSymmetricKey == null) { + Log.e(TAG, "Invalid symmetric key format, aborting connection") + isConnecting.set(false) + onConnectionStatus?.invoke(false) + notifyConnectionStatusListeners(false) + try { + AirSyncWidgetProvider.updateAllWidgets(context) + } catch (_: Exception) { + } + return@launch + } + // Keep relay key aligned with current active key for seamless LAN<->relay switching. + AirBridgeClient.updateSymmetricKey(symmetricKey) onConnectionStatusChanged = onConnectionStatus onMessageReceived = onMessage @@ -325,6 +489,10 @@ object WebSocketUtil { onConnectionStatusChanged?.invoke(true) notifyConnectionStatusListeners(true) cancelAutoReconnect() + stopLanFirstRelayProbe("lan_handshake_completed") + // Keep relay warm in background (if enabled) for instant failover. + AirBridgeClient.ensureConnected(context, immediate = false) + notifyPeerTransportChanged("wifi", force = true) try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -373,7 +541,16 @@ object WebSocketUtil { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - tryStartAutoReconnect(context) + // If relay is enabled, force immediate relay reconnect for seamless fallback. + AirBridgeClient.ensureConnected(context, immediate = true) + notifyPeerTransportChanged("relay", force = true) + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + startLanFirstRelayProbe(context, immediate = true, source = "lan_onClosing", resetBackoff = true) + } + Log.w(TAG, "LAN socket closing, requested immediate relay fallback") + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + tryStartAutoReconnect(context) + } try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -393,7 +570,8 @@ object WebSocketUtil { if (wasActive || isFinalManualAttempt) { if (manualAttempt || isSocketOpen.get()) { - if (com.sameerasw.airsync.AirSyncApp.isAppForeground()) { + // Avoid noisy LAN error toasts when relay failover is already available. + if (!AirBridgeClient.isRelayConnectedOrConnecting() && com.sameerasw.airsync.AirSyncApp.isAppForeground()) { CoroutineScope(Dispatchers.Main).launch { val msg = when (t) { is java.net.ConnectException -> "Connection Refused (Is AirSync Mac running?)" @@ -426,7 +604,16 @@ object WebSocketUtil { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - tryStartAutoReconnect(context) + // If relay is enabled, force immediate relay reconnect for seamless fallback. + AirBridgeClient.ensureConnected(context, immediate = true) + notifyPeerTransportChanged("relay", force = true) + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + startLanFirstRelayProbe(context, immediate = true, source = "lan_onFailure", resetBackoff = true) + } + Log.w(TAG, "LAN failure, requested immediate relay fallback") + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + tryStartAutoReconnect(context) + } try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -494,8 +681,11 @@ object WebSocketUtil { } ?: message webSocket!!.send(messageToSend) + } else if (AirBridgeClient.isRelayActive()) { + // Fallback: route through AirBridge relay if local connection is down + AirBridgeClient.sendMessage(message) } else { - Log.w(TAG, "WebSocket not connected, cannot send message") + Log.w(TAG, "Drop TX: no LAN/relay available") false } } @@ -515,6 +705,9 @@ object WebSocketUtil { // Stop periodic sync when disconnecting SyncManager.stopPeriodicSync() + lastAdvertisedTransport.set(null) + stopLanFirstRelayProbe("manual_disconnect") + resetLanProbeFailureState("manual_disconnect") webSocket?.close(1000, "Manual disconnection") webSocket = null @@ -576,6 +769,8 @@ object WebSocketUtil { onMessageReceived = null handshakeCompleted.set(false) handshakeTimeoutJob?.cancel() + stopLanFirstRelayProbe("cleanup") + resetLanProbeFailureState("cleanup") appContext = null } @@ -656,6 +851,10 @@ object WebSocketUtil { // Monitor discovered devices UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + startLanFirstRelayProbe(context, immediate = false, source = "auto_reconnect_collect", resetBackoff = false) + return@collect + } val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() @@ -712,7 +911,10 @@ object WebSocketUtil { } } } catch (e: Exception) { - Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") + if (e is kotlinx.coroutines.CancellationException) { + } else { + Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") + } } } } @@ -721,6 +923,377 @@ object WebSocketUtil { fun requestAutoReconnect(context: Context) { // Only if not already connected or connecting if (isConnected.get() || isConnecting.get()) return + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + // Important for app cold-start/reopen after process kill: + // there may be no immediate network callback/discovery emission, + // so start LAN-first probe right away while relay is up. + sendTransportOffer(context, reason = "requestAutoReconnect_relay_bootstrap") + startLanFirstRelayProbe( + context = context, + immediate = true, + source = "requestAutoReconnect_relay_bootstrap", + resetBackoff = true + ) + } tryStartAutoReconnect(context) } + + /** + * Attempts to re-establish a direct LAN connection while relay is active. + * Called when WiFi becomes available again after being lost. + * On success the existing relay stays warm but message routing automatically + * prefers LAN via sendMessage(). + */ + fun requestLanReconnectFromRelay(context: Context) { + requestLanReconnectFromRelay(context, source = "default") + } + + fun requestLanReconnectFromRelay(context: Context, source: String) { + if (!isLanNegotiationAllowed(context)) { + return + } + val now = System.currentTimeMillis() + val cooldownUntil = lanProbeCooldownUntilMs.get() + if (cooldownUntil > now) { + return + } + if (isConnected.get() || isConnecting.get()) return + val last = lastRelayLanRetryMs.get() + if (now - last < 5_000L && source.startsWith("periodic:")) { + return + } + lastRelayLanRetryMs.set(now) + + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val manual = ds.getUserManuallyDisconnected().first() + val autoEnabled = ds.getAutoReconnectEnabled().first() + if (manual || !autoEnabled) { + return@launch + } + + val last = ds.getLastConnectedDevice().first() ?: return@launch + val all = ds.getAllNetworkDeviceConnections().first() + val targetConnection = all.firstOrNull { it.deviceName == last.name } + + if (targetConnection != null) { + // Discover fresh IPs via UDP burst first, but throttle to avoid battery drain. + val burstNow = System.currentTimeMillis() + val lastBurst = lastLanProbeDiscoveryBurstMs.get() + val shouldBurst = source.startsWith("start:") || + source.startsWith("immediate:") || + burstNow - lastBurst >= RELAY_LAN_PROBE_DISCOVERY_MIN_INTERVAL_MS + if (shouldBurst && lastLanProbeDiscoveryBurstMs.compareAndSet(lastBurst, burstNow)) { + UDPDiscoveryManager.burstBroadcast(context) + } + delay(2000) // Allow time for discovery responses + + // Check discovered devices for the target + val discovered = UDPDiscoveryManager.discoveredDevices.value + val match = discovered.find { it.name == last.name } + + val ips = match?.ips?.joinToString(",") + ?: targetConnection.getClientIpForNetwork(DeviceInfoUtil.getWifiIpAddress(context) ?: "") + ?: last.ipAddress + val port = targetConnection.port.toIntOrNull() ?: 6996 + + connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = targetConnection.symmetricKey, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + resetLanProbeFailureState("lan_reconnect_success") + CoroutineScope(Dispatchers.IO).launch { + try { + ds.updateNetworkDeviceLastConnected( + targetConnection.deviceName, + System.currentTimeMillis() + ) + } catch (_: Exception) {} + } + } else { + markLanProbeFailure("lan_reconnect_failed:$source") + } + } + ) + } else { + markLanProbeFailure("missing_target_connection:$source") + // Fall back to generic auto-reconnect which monitors discovery + tryStartAutoReconnect(context) + } + } catch (e: Exception) { + markLanProbeFailure("request_exception:$source") + Log.e(TAG, "Error in requestLanReconnectFromRelay") + } + } + } + + private fun resetLanProbeFailureState(reason: String) { + consecutiveLanProbeFailures.set(0) + lanProbeCooldownUntilMs.set(0L) + } + + private fun markLanProbeFailure(reason: String) { + val fails = consecutiveLanProbeFailures.incrementAndGet() + if (fails >= RELAY_LAN_PROBE_MAX_CONSECUTIVE_FAILURES) { + val until = System.currentTimeMillis() + RELAY_LAN_PROBE_COOLDOWN_MS + lanProbeCooldownUntilMs.set(until) + consecutiveLanProbeFailures.set(0) + Log.w(TAG, "LAN-first probe entering cooldown after repeated failures") + } + } + + fun reportLanNegotiationFailure(reason: String) { + markLanProbeFailure("negotiation:$reason") + } + + fun reportLanNegotiationSuccess(reason: String) { + resetLanProbeFailureState("negotiation:$reason") + } + + fun nextTransportGeneration(): Long { + val next = transportGeneration.incrementAndGet() + beginTransportRound(next, "local_next_generation") + return next + } + + private fun beginTransportRound(generation: Long, reason: String) { + if (generation <= 0L) return + activeTransportGeneration.set(generation) + activeTransportGenerationStartedAtMs.set(System.currentTimeMillis()) + validatedTransportGeneration.set(0L) + pendingTransportCheckGeneration.set(0L) + pendingTransportCheckToken.set(null) + transportCheckTimeoutJob?.cancel() + transportCheckTimeoutJob = null + } + + fun acceptIncomingTransportGeneration(generation: Long, reason: String): Boolean { + if (generation <= 0L) return false + val current = activeTransportGeneration.get() + if (current == 0L) { + beginTransportRound(generation, "incoming_init:$reason") + return true + } + if (generation == current) return true + + val age = System.currentTimeMillis() - activeTransportGenerationStartedAtMs.get() + if (age > TRANSPORT_GENERATION_TTL_MS && generation > current) { + beginTransportRound(generation, "incoming_rollover:$reason") + return true + } + + Log.w(TAG, "Dropping stale transport generation update") + return false + } + + fun isTransportGenerationActive(generation: Long): Boolean { + if (generation <= 0L) return false + val current = activeTransportGeneration.get() + if (generation != current) return false + val age = System.currentTimeMillis() - activeTransportGenerationStartedAtMs.get() + return age <= TRANSPORT_GENERATION_TTL_MS + } + + fun markTransportGenerationValidated(generation: Long, reason: String) { + if (!isTransportGenerationActive(generation)) return + validatedTransportGeneration.set(generation) + } + + fun isTransportGenerationValidated(generation: Long): Boolean { + return generation > 0L && validatedTransportGeneration.get() == generation + } + + fun getActiveTransportGeneration(): Long { + return activeTransportGeneration.get() + } + + fun sendTransportOffer(context: Context, reason: String, generation: Long = nextTransportGeneration()): Boolean { + if (!isLanNegotiationAllowed(context)) { + return false + } + beginTransportRound(generation, "send_offer:$reason") + val localIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "" + val candidates = JSONArray().apply { + if (localIp.isNotBlank()) { + put(JSONObject().apply { + put("ip", localIp) + put("port", 0) + put("type", "host") + }) + } + } + val payload = JSONObject().apply { + put("type", "transportOffer") + put("data", JSONObject().apply { + put("source", "android") + put("generation", generation) + put("candidates", candidates) + put("ts", System.currentTimeMillis()) + put("reason", reason) + }) + }.toString() + + return sendMessage(payload) + } + + fun sendTransportAnswer(generation: Long, reason: String, context: Context? = appContext): Boolean { + if (context == null || !isLanNegotiationAllowed(context)) { + return false + } + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "Dropping transport answer for inactive generation") + return false + } + val localIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "" + val candidates = JSONArray().apply { + if (localIp.isNotBlank()) { + put(JSONObject().apply { + put("ip", localIp) + put("port", 0) + put("type", "host") + }) + } + } + val payload = JSONObject().apply { + put("type", "transportAnswer") + put("data", JSONObject().apply { + put("source", "android") + put("generation", generation) + put("candidates", candidates) + put("ts", System.currentTimeMillis()) + put("reason", reason) + }) + }.toString() + return sendMessage(payload) + } + + fun sendTransportCheck(generation: Long, reason: String): Boolean { + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "Dropping transport check for inactive generation") + return false + } + val token = UUID.randomUUID().toString() + pendingTransportCheckGeneration.set(generation) + pendingTransportCheckToken.set(token) + transportCheckTimeoutJob?.cancel() + transportCheckTimeoutJob = CoroutineScope(Dispatchers.IO).launch { + delay(TRANSPORT_CHECK_TIMEOUT_MS) + val pendingToken = pendingTransportCheckToken.get() + if (pendingToken == token) { + Log.w(TAG, "Transport check timed out") + reportLanNegotiationFailure("check_timeout") + sendTransportNominate("relay", generation, "check_timeout") + } + } + + val payload = JSONObject().apply { + put("type", "transportCheck") + put("data", JSONObject().apply { + put("source", "android") + put("generation", generation) + put("token", token) + put("ts", System.currentTimeMillis()) + put("reason", reason) + }) + }.toString() + return sendMessage(payload) + } + + fun sendTransportCheckAck(generation: Long, token: String): Boolean { + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "Dropping transport check-ack for inactive generation") + return false + } + val payload = JSONObject().apply { + put("type", "transportCheckAck") + put("data", JSONObject().apply { + put("source", "android") + put("generation", generation) + put("token", token) + put("ts", System.currentTimeMillis()) + }) + }.toString() + return sendMessage(payload) + } + + fun onTransportCheckAck(generation: Long, token: String) { + if (!isTransportGenerationActive(generation)) { + return + } + val pendingGeneration = pendingTransportCheckGeneration.get() + val pendingToken = pendingTransportCheckToken.get() + if (pendingGeneration != generation || pendingToken != token) { + return + } + transportCheckTimeoutJob?.cancel() + transportCheckTimeoutJob = null + pendingTransportCheckToken.set(null) + pendingTransportCheckGeneration.set(0L) + reportLanNegotiationSuccess("check_ack") + if (!isConnected()) { + Log.w(TAG, "Dropping transport check-ack because LAN is not connected") + return + } + markTransportGenerationValidated(generation, "check_ack") + notifyPeerTransportChanged("wifi", force = true) + sendTransportNominate("lan", generation, "check_ack") + } + + fun sendTransportNominate(path: String, generation: Long, reason: String): Boolean { + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "Dropping transport nominate for inactive generation") + return false + } + if (path == "lan" && !isTransportGenerationValidated(generation)) { + Log.w(TAG, "Dropping LAN nominate because generation is not validated") + return false + } + val payload = JSONObject().apply { + put("type", "transportNominate") + put("data", JSONObject().apply { + put("source", "android") + put("generation", generation) + put("path", path) + put("ts", System.currentTimeMillis()) + put("reason", reason) + }) + }.toString() + return sendMessage(payload) + } + + private fun isPrivateLanIp(ip: String): Boolean { + if (ip.startsWith("192.168.") || ip.startsWith("10.")) return true + if (ip.startsWith("172.")) { + val parts = ip.split(".") + if (parts.size >= 2) { + val secondOctet = parts[1].toIntOrNull() + if (secondOctet != null && secondOctet in 16..31) { + return true + } + } + } + return false + } + + fun isLanNegotiationAllowed(context: Context): Boolean { + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + val isLanTransport = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + if (!isLanTransport) return false + val ip = DeviceInfoUtil.getWifiIpAddress(context) ?: return false + isPrivateLanIp(ip) + } catch (_: Exception) { + false + } + } + + }