From f6f2f421c4348d8c231a38c4489eaddf67a8fbe8 Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sat, 14 Mar 2026 18:20:16 +0100 Subject: [PATCH 01/29] feat: AirBridge relay support and LAN authentication - Introduced `AirBridgeClient`, a WebSocket-based relay system allowing remote connections when devices are not on the same network. - Implemented a challenge-response authentication mechanism for LAN connections using HMAC-SHA256. - Added `AirBridgeCard` to settings for configuring relay server URL, pairing ID, and secret. - Enhanced QR code parsing to support AirBridge credentials and modern URL parameter separators (`&`). - Updated `WebSocketUtil` to support seamless failover between LAN and relay transports. - Implemented a thread-safe nonce replay guard in `CryptoUtil` to prevent replay attacks on encrypted messages. - Added a "Connect with Relay" option to the last connected device card. - Improved `FileReceiver` to handle duplicate chunks and prevent progress notification spam. - Unified connection state monitoring in `AirSyncViewModel` to reflect both LAN and relay status. --- .../java/com/sameerasw/airsync/AirSyncApp.kt | 22 + .../com/sameerasw/airsync/MainActivity.kt | 71 ++- .../airsync/data/local/DataStoreManager.kt | 40 ++ .../ui/components/SettingsView.kt | 3 + .../ui/components/cards/AirBridgeCard.kt | 237 ++++++++ .../cards/LastConnectedDeviceCard.kt | 44 +- .../ui/screens/AirSyncMainScreen.kt | 119 +++- .../viewmodel/AirSyncViewModel.kt | 50 +- .../airsync/utils/AirBridgeClient.kt | 520 ++++++++++++++++++ .../com/sameerasw/airsync/utils/CryptoUtil.kt | 113 +++- .../sameerasw/airsync/utils/FileReceiver.kt | 31 +- .../sameerasw/airsync/utils/WebSocketUtil.kt | 188 ++++++- 12 files changed, 1346 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/AirBridgeCard.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 4843db3c..42d66de0 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -2,14 +2,20 @@ package com.sameerasw.airsync import android.app.Application 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() { override fun onCreate() { super.onCreate() initSentry() + initAirBridge() } private fun initSentry() { @@ -23,4 +29,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 b9ab9e5b..2f7883d2 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,8 +63,8 @@ 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 object AdbDiscoveryHolder { private var discovery: AdbMdnsDiscovery? = null @@ -342,21 +344,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"] } } @@ -574,6 +603,24 @@ class MainActivity : ComponentActivity() { return AdbDiscoveryHolder.getDiscoveredServices() } + private fun parseAirsyncParams(urlString: String): Map { + val queryPart = urlString.substringAfter('?', "") + if (queryPart.isEmpty()) return emptyMap() + + // Backward-compatible parsing: + // old QR used "?" as separator, newer QR may use "&". + return queryPart.split("[?&]".toRegex()) + .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 5afd5d4b..b9264fdf 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 @@ -95,6 +95,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_" @@ -591,6 +597,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/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 2cfded78..1aab191c 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 @@ -350,6 +351,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 00000000..a6bd810b --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/AirBridgeCard.kt @@ -0,0 +1,237 @@ +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() + + 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", 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.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.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) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Relay URL + OutlinedTextField( + value = relayUrl, + onValueChange = { relayUrl = it }, + label = { Text("Relay Server URL") }, + placeholder = { Text("wss://airbridge") }, + 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/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 65e0b58c..6ef1b7de 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 @@ -137,23 +138,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 = 8.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") + } } } 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 ac450ed4..425200c9 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 @@ -1,5 +1,6 @@ package com.sameerasw.airsync.presentation.ui.screens +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.util.Log @@ -23,13 +24,10 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -51,14 +49,11 @@ import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults -import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset -import androidx.compose.material3.FloatingToolbarExitDirection.Companion.Bottom import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -79,7 +74,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -107,6 +101,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 @@ -121,13 +117,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject -import java.net.URLDecoder @OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class ) +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable fun AirSyncMainScreen( modifier: Modifier = Modifier, @@ -206,8 +202,6 @@ fun AirSyncMainScreen( val navCallbackState = rememberUpdatedState(onNavigateToApps) LaunchedEffect(navCallbackState.value) { } - var fabVisible by remember { mutableStateOf(true) } - var fabExpanded by remember { mutableStateOf(true) } var showKeyboard by remember { mutableStateOf(false) } // State for Keyboard Sheet in Remote Tab var showHelpSheet by remember { mutableStateOf(false) } val onDismissHelp = { showHelpSheet = false } @@ -428,23 +422,34 @@ fun AirSyncMainScreen( } } - // Parse query parameters + // Parse query parameters (legacy '?' and modern '&' separators) 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("[?&]".toRegex()) + .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()) { @@ -459,6 +464,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() } @@ -545,17 +580,13 @@ fun AirSyncMainScreen( val last = state.value snapshotFlow { state.value }.collect { value -> val delta = value - last - if (delta > 2) fabVisible = false - else if (delta < -2) fabVisible = true } } // Expand FAB on first launch and whenever variant changes (connect <-> disconnect), then collapse after 5s LaunchedEffect(uiState.isConnected) { - fabExpanded = true // Give users a hint for a short period, then collapse to icon-only delay(5000) - fabExpanded = false } // Start/stop clipboard sync based on connection status and settings @@ -676,7 +707,7 @@ fun AirSyncMainScreen( contentWindowInsets = WindowInsets(0, 0, 0, 0), modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.surfaceContainer, - ) { innerPadding -> + ) { _ -> val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val topSpacing = (statusBarHeight - 24.dp).coerceAtLeast(0.dp) @@ -831,6 +862,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 a4094ae6..5f0fa88f 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,36 @@ 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, + 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 +146,12 @@ class AirSyncViewModel( WebSocketUtil.registerManualConnectListener(manualConnectCanceler) } catch (_: Exception) { } + viewModelScope.launch { + AirBridgeClient.connectionState.collect { relayState -> + lastRelayState = relayState + updateUnifiedConnectionState() + } + } // Observe Mac device status updates viewModelScope.launch { @@ -302,7 +329,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, @@ -318,6 +352,7 @@ class AirSyncViewModel( isClipboardSyncEnabled = isClipboardSyncEnabled, isAutoReconnectEnabled = isAutoReconnectEnabled, isConnected = currentlyConnected, + isConnecting = currentlyConnecting, symmetricKey = symmetricKey ?: lastConnectedSymmetricKey, isContinueBrowsingEnabled = isContinueBrowsingEnabled, isSendNowPlayingEnabled = isSendNowPlayingEnabled, @@ -333,6 +368,7 @@ class AirSyncViewModel( isSentryReportingEnabled = isSentryReportingEnabled, isOnboardingCompleted = !isFirstRun ) + lastUnifiedConnected = currentlyConnected updateRatingPromptDisplay() 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 00000000..d5c4afdb --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -0,0 +1,520 @@ +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 javax.crypto.SecretKey + +/** + * Singleton that manages the WebSocket connection to the AirBridge relay server. + * Runs alongside the local WebSocket connection as a fallback for remote communication. + */ +object AirBridgeClient { + private const val TAG = "AirBridgeClient" + + // Connection state + enum class State { + DISCONNECTED, + CONNECTING, + 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 + + // 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) + + // 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) { + val oldState = _connectionState.value + if (oldState != newState) { + Log.i(TAG, "State: $oldState -> $newState | $reason") + } else { + Log.d(TAG, "State unchanged: $newState | $reason") + } + _connectionState.value = newState + } + + /** + * Connects to the AirBridge relay server. + * Reads configuration from DataStore. + */ + fun connect(context: Context) { + appContext = context.applicationContext + isManuallyDisconnected.set(false) + + 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 secret = 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() || secret.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 + } else { + Log.d(TAG, "Symmetric key resolved for relay transport") + } + + connectInternal(relayUrl, pairingId, hashSecret(secret)) + } catch (e: Exception) { + Log.e(TAG, "Failed to read AirBridge config: ${e.message}") + 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) } + if (symmetricKey != null) { + Log.d(TAG, "Relay symmetric key updated from active session") + } + } + + // 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) { + Log.d(TAG, "ensureConnected skipped: AirBridge disabled") + 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.CONNECTING -> { + // Already connected/connecting + return@launch + } + else -> { + if (immediate) { + reconnectJob?.cancel() + reconnectJob = null + reconnectAttempt = 0 + Log.i(TAG, "ensureConnected: forcing immediate reconnect") + } + connect(context) + } + } + } catch (e: Exception) { + Log.e(TAG, "ensureConnected failed: ${e.message}") + } + } + } + + /** + * Disconnects from the relay server. Disables auto-reconnect. + */ + fun disconnect() { + isManuallyDisconnected.set(true) + reconnectJob?.cancel() + reconnectJob = null + reconnectAttempt = 0 + + // Close WebSocket connection gracefully. + try { + webSocket?.close(1000, "Manual disconnect") + } catch (_: Exception) {} + webSocket = null + + client?.dispatcher?.executorService?.shutdown() + client = null + + setState(State.DISCONNECTED, "Manual disconnect") + Log.d(TAG, "Disconnected manually") + } + + /** + * 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 + } + + val type = try { + JSONObject(message).optString("type", "unknown") + } catch (_: Exception) { + "non_json" + } + + return try { + Log.d(TAG, "Relay TX type=$type bytes=${messageToSend.length}") + ws.send(messageToSend) + } catch (e: Exception) { + Log.e(TAG, "Failed to send relay message: ${e.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.CONNECTING -> true + else -> false + } + } + + /** + * Sets the message handler. Called once during app initialization. + */ + fun setMessageHandler(handler: (Context, String) -> Unit) { + onMessageReceived = handler + } + + /** + * Internal function to establish WebSocket connection and handle relay protocol. + */ + private fun connectInternal(relayUrl: String, pairingId: String, secret: String) { + // 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) + Log.d(TAG, "Connecting to relay: $normalizedUrl") + + // 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(15, TimeUnit.SECONDS) + .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() { + // On open, send the registration message with the pairing ID and secret hash. + override fun onOpen(ws: WebSocket, response: Response) { + Log.d(TAG, "WebSocket opened to relay") + webSocket = ws + setState(State.REGISTERING, "Socket open, sending register") + reconnectAttempt = 0 + + // Send registration + val regMsg = JSONObject().apply { + put("action", "register") + put("role", "android") + put("pairingId", pairingId) + put("secret", secret) + put("localIp", DeviceInfoUtil.getWifiIpAddress(appContext!!) ?: "unknown") + put("port", 0) // Android doesn't run a server it's the client + } + + if (ws.send(regMsg.toString())) { + Log.d(TAG, "Registration sent for pairingId: $pairingId") + setState(State.WAITING_FOR_PEER, "Registration accepted, waiting peer") + } else { + Log.e(TAG, "Failed to send registration") + setState(State.FAILED, "Registration send failed") + } + } + + override fun onMessage(ws: WebSocket, text: String) { + handleTextMessage(text) + } + + override fun onClosing(ws: WebSocket, code: Int, reason: String) { + Log.d(TAG, "Relay closing: $code $reason") + ws.close(1000, null) + setState(State.DISCONNECTED, "Socket closing code=$code") + scheduleReconnect(relayUrl, pairingId, secret) + } + + override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "Relay connection failed: ${t.message}") + setState(State.FAILED, "Socket failure: ${t.message}") + webSocket = null + scheduleReconnect(relayUrl, pairingId, secret) + } + } + + client!!.newWebSocket(request, listener) + } + + private fun handleTextMessage(text: String) { + // First, try to parse as an AirBridge control message + try { + val json = JSONObject(text) + val action = json.optString("action", "") + + when (action) { + "relay_started" -> { + Log.i(TAG, "Relay tunnel established!") + setState(State.RELAY_ACTIVE, "Server confirmed relay tunnel") + + // Trigger initial sync via relay now that the tunnel is active + appContext?.let { ctx -> + CoroutineScope(Dispatchers.IO).launch { + try { + SyncManager.performInitialSync(ctx) + } catch (e: Exception) { + Log.e(TAG, "Failed to perform initial sync via relay: ${e.message}") + } + } + } + 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 + Log.d(TAG, "Received mac_info from relay") + } + "error" -> { + val msg = json.optString("message", "Unknown error") + Log.e(TAG, "Relay server error: $msg") + 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. + // Never accept plaintext relay messages — refuse if no key is available. + val key = symmetricKey + if (key == null) { + Log.e(TAG, "SECURITY: Cannot decrypt relay message — no symmetric key available. Dropping the message.") + return + } + + val decrypted = CryptoUtil.decryptMessage(text, key) + if (decrypted == null) { + Log.e(TAG, "SECURITY: Decryption failed for relay message (corrupted, tampered, or replay). Dropping.") + return + } + + appContext?.let { ctx -> + onMessageReceived?.invoke(ctx, decrypted) + } + } + + // 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, secret: String) { + if (isManuallyDisconnected.get()) return + + val delayMs = minOf( + (1L shl minOf(reconnectAttempt, 10)) * 1000L, + maxReconnectDelay + ) + reconnectAttempt++ + + Log.d(TAG, "Reconnecting in ${delayMs}ms (attempt $reconnectAttempt)") + setState(State.CONNECTING, "Backoff reconnect scheduled in ${delayMs}ms") + + reconnectJob?.cancel() + reconnectJob = CoroutineScope(Dispatchers.IO).launch { + delay(delayMs) + if (!isManuallyDisconnected.get()) { + connectInternal(relayUrl, pairingId, secret) + } + } + } + + /** + * SHA-256 hashes the raw secret so the plaintext never leaves the device. + * The relay server only ever sees (and stores) this hash. + */ + private fun hashSecret(raw: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(raw.toByteArray(Charsets.UTF_8)) + return hash.joinToString("") { "%02x".format(it) } + } + + /** + * 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: $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/CryptoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt index afc136c4..a00bbc85 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt @@ -1,22 +1,89 @@ package com.sameerasw.airsync.utils import android.util.Base64 +import android.util.Log +import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.security.SecureRandom +import java.util.LinkedList import javax.crypto.Cipher +import javax.crypto.Mac import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec +/** + * Thread-safe nonce replay cache to prevent replay attacks on AES-GCM messages. + * Tracks recently-seen 12-byte nonces and rejects duplicates. + */ +private object NonceReplayGuard { + private const val MAX_ENTRIES = 10_000 + private val lock = Any() + private val seenNonces = HashSet(MAX_ENTRIES) + private val nonceOrder = LinkedList() + + /** + * Returns `true` if the nonce has NOT been seen before (message is fresh). + * Returns `false` if the nonce is a duplicate (replay detected). + */ + fun checkAndRecord(nonce: ByteArray): Boolean { + val key = ByteBuffer.wrap(nonce.copyOf()) + synchronized(lock) { + if (seenNonces.contains(key)) { + return false // replay + } + seenNonces.add(key) + nonceOrder.addLast(key) + // Evict oldest entries when cache is full + if (nonceOrder.size > MAX_ENTRIES) { + val evict = nonceOrder.removeFirst() + seenNonces.remove(evict) + } + return true + } + } + + /** Clears the replay cache (e.g. on key rotation or reconnect). */ + fun reset() { + synchronized(lock) { + seenNonces.clear() + nonceOrder.clear() + } + } +} + object CryptoUtil { + private const val TAG = "CryptoUtil" private const val AES_GCM_NOPADDING = "AES/GCM/NoPadding" private const val NONCE_SIZE_BYTES = 12 private const val TAG_SIZE_BITS = 128 - fun decodeKey(base64Key: String): SecretKey { - val keyBytes = Base64.decode(base64Key, Base64.DEFAULT) - return SecretKeySpec(keyBytes, "AES") + fun decodeKey(base64Key: String): SecretKey? { + return try { + // Accept standard and URL-safe Base64 variants. + var normalized = base64Key.trim() + .replace(" ", "+") + .replace("-", "+") + .replace("_", "/") + .replace("\n", "") + .replace("\r", "") + + // Ensure valid padding length for Base64 decoder. + val pad = normalized.length % 4 + if (pad != 0) { + normalized += "=".repeat(4 - pad) + } + + val keyBytes = Base64.decode(normalized, Base64.DEFAULT) + if (keyBytes.isEmpty()) { + null + } else { + SecretKeySpec(keyBytes, "AES") + } + } catch (_: IllegalArgumentException) { + null + } } fun decryptMessage(base64Combined: String, key: SecretKey): String? { @@ -28,6 +95,13 @@ object CryptoUtil { } val nonce = combined.copyOfRange(0, NONCE_SIZE_BYTES) + + // Anti-replay: check that this nonce hasn't been used before + if (!NonceReplayGuard.checkAndRecord(nonce)) { + Log.w(TAG, "Replay detected: duplicate nonce, dropping message") + return null + } + val ciphertextWithTag = combined.copyOfRange(NONCE_SIZE_BYTES, combined.size) val cipher = Cipher.getInstance(AES_GCM_NOPADDING) @@ -60,5 +134,38 @@ object CryptoUtil { null } } + + /** + * Computes HMAC-SHA256 over the given data using the raw bytes of the provided key. + * Returns the result as a lowercase hex string. + */ + fun hmacSha256(key: SecretKey, data: ByteArray): String { + val mac = Mac.getInstance("HmacSHA256") + val hmacKey = SecretKeySpec(key.encoded, "HmacSHA256") + mac.init(hmacKey) + val result = mac.doFinal(data) + return result.joinToString("") { "%02x".format(it) } + } + + /** + * Decodes a hex string into a byte array. + */ + fun hexToBytes(hex: String): ByteArray { + val len = hex.length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(hex[i], 16) shl 4) + Character.digit(hex[i + 1], 16)).toByte() + i += 2 + } + return data + } + + /** + * Resets the replay nonce cache (call on key rotation or reconnect). + */ + fun resetReplayGuard() { + NonceReplayGuard.reset() + } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt b/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt index 1096eaa3..596ace01 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt @@ -1,5 +1,6 @@ package com.sameerasw.airsync.utils +import android.Manifest import android.content.ContentValues import android.content.Context import android.net.Uri @@ -7,6 +8,7 @@ import android.os.Build import android.provider.MediaStore import android.util.Log import android.widget.Toast +import androidx.annotation.RequiresPermission import androidx.core.app.NotificationManagerCompat import com.sameerasw.airsync.R import com.sameerasw.airsync.utils.transfer.FileTransferProtocol @@ -29,6 +31,7 @@ object FileReceiver { var index: Int = 0, var pfd: android.os.ParcelFileDescriptor? = null, var uri: Uri? = null, + val receivedChunkIndexes: MutableSet = mutableSetOf(), // Speed / ETA tracking var lastUpdateTime: Long = System.currentTimeMillis(), var bytesAtLastUpdate: Int = 0, @@ -148,18 +151,25 @@ object FileReceiver { val state = incoming[id] ?: return@launch val bytes = android.util.Base64.decode(base64Chunk, android.util.Base64.NO_WRAP) + var shouldUpdateProgress = false synchronized(state) { - state.pfd?.fileDescriptor?.let { fd -> - val channel = java.io.FileOutputStream(fd).channel - val offset = index.toLong() * state.chunkSize - channel.position(offset) - channel.write(java.nio.ByteBuffer.wrap(bytes)) - state.receivedBytes += bytes.size - state.index = index + // Ignore duplicate chunks (common with retransmissions on lossy links/relay). + if (state.receivedChunkIndexes.add(index)) { + state.pfd?.fileDescriptor?.let { fd -> + val channel = java.io.FileOutputStream(fd).channel + val offset = index.toLong() * state.chunkSize + channel.position(offset) + channel.write(java.nio.ByteBuffer.wrap(bytes)) + state.receivedBytes += bytes.size + state.index = index + shouldUpdateProgress = true + } } } - updateProgressNotification(context, id, state) + if (shouldUpdateProgress) { + updateProgressNotification(context, id, state) + } // send ack for this chunk try { val ack = FileTransferProtocol.buildChunkAck(id, index) @@ -277,6 +287,7 @@ object FileReceiver { } + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private fun updateProgressNotification(context: Context, id: String, state: IncomingFileState) { if (state.isClipboard) return @@ -312,7 +323,9 @@ object FileReceiver { state.lastUpdateTime = now state.bytesAtLastUpdate = state.receivedBytes - val percent = if (state.size > 0) (state.receivedBytes * 100 / state.size) else 0 + val percent = if (state.size > 0) { + (state.receivedBytes * 100 / state.size).coerceIn(0, 100) + } else 0 NotificationUtil.showFileProgress( context, id.hashCode(), 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 1beb006c..648be562 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -54,6 +54,15 @@ object WebSocketUtil { // Application context for side-effects (notifications/services) when explicit context isn't provided private var appContext: Context? = null + // Handle LAN authentication challenge by responding with HMAC of the nonce using the symmetric key + private fun extractMessageType(message: String): String { + return try { + org.json.JSONObject(message).optString("type", "unknown") + } catch (_: Exception) { + "non_json" + } + } + // Global connection status listeners for UI updates private val connectionStatusListeners = mutableSetOf<(Boolean) -> Unit>() @@ -155,7 +164,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 @@ -222,10 +249,9 @@ object WebSocketUtil { isConnected.set(false) isConnecting.set(true) - try { - SyncManager.performInitialSync(context) - } catch (_: Exception) { - } + // Note: performInitialSync is deferred until after LAN auth succeeds. + // The Mac will send an authChallenge first; we respond with HMAC. + // Once authResult(success=true) arrives, we trigger initial sync. handshakeTimeoutJob?.cancel() handshakeTimeoutJob = CoroutineScope(Dispatchers.IO).launch { @@ -263,9 +289,48 @@ object WebSocketUtil { } override fun onMessage(webSocket: WebSocket, text: String) { - val decryptedMessage = currentSymmetricKey?.let { key -> - CryptoUtil.decryptMessage(text, key) - } ?: text + // Never accept plaintext LAN messages — refuse if no key. + val key = currentSymmetricKey + if (key == null) { + Log.e(TAG, "SECURITY: Received LAN message but no symmetric key. Dropping.") + return + } + val decryptedMessage = CryptoUtil.decryptMessage(text, key) + if (decryptedMessage == null) { + Log.e(TAG, "SECURITY: LAN message decryption failed (corrupted/tampered/replay). Dropping.") + return + } + + // Handle LAN authentication challenge-response + val msgType = try { + org.json.JSONObject(decryptedMessage).optString("type", "") + } catch (_: Exception) { "" } + + if (msgType == "authChallenge") { + handleAuthChallenge(decryptedMessage, webSocket) + return + } + + if (msgType == "authResult") { + val authData = try { + org.json.JSONObject(decryptedMessage).optJSONObject("data") + } catch (_: Exception) { null } + val success = authData?.optBoolean("success", false) ?: false + if (!success) { + val reason = authData?.optString("reason", "Unknown") ?: "Unknown" + Log.e(TAG, "LAN authentication failed: $reason") + webSocket.close(4003, "Auth failed: $reason") + return + } + Log.i(TAG, "LAN authentication succeeded, starting initial sync") + // Auth passed — now safe to send device info / initial sync + try { + SyncManager.performInitialSync(context) + } catch (_: Exception) { + } + // Continue — next message should be macInfo for handshake + return + } if (!handshakeCompleted.get()) { val handshakeOk = try { @@ -322,6 +387,9 @@ object WebSocketUtil { onConnectionStatusChanged?.invoke(true) notifyConnectionStatusListeners(true) cancelAutoReconnect() + // Keep relay warm in background (if enabled) for instant failover. + AirBridgeClient.ensureConnected(context, immediate = false) + Log.i(TAG, "LAN handshake completed on $ip:$port, relay kept warm") try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -368,7 +436,14 @@ 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) + Log.w(TAG, "LAN socket closing, requested immediate relay fallback") + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + tryStartAutoReconnect(context) + } else { + Log.d(TAG, "Skipping LAN auto-reconnect: relay already connected/connecting") + } try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -388,15 +463,18 @@ object WebSocketUtil { if (wasActive || isFinalManualAttempt) { if (manualAttempt || isSocketOpen.get()) { - CoroutineScope(Dispatchers.Main).launch { - val msg = when (t) { - is java.net.ConnectException -> "Connection Refused (Is AirSync Mac running?)" - is java.net.SocketTimeoutException -> "Could not discover your mac" - is java.net.UnknownHostException -> "Could not reach your mac" - is java.io.EOFException, is java.net.SocketException -> "Lost connection to your mac" - else -> t.message ?: "Unknown connection error" + // Avoid noisy LAN error toasts when relay failover is already available. + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + CoroutineScope(Dispatchers.Main).launch { + val msg = when (t) { + is java.net.ConnectException -> "Connection Refused (Is AirSync Mac running?)" + is java.net.SocketTimeoutException -> "Could not discover your mac" + is java.net.UnknownHostException -> "Could not reach your mac" + is java.io.EOFException, is java.net.SocketException -> "Lost connection to your mac" + else -> t.message ?: "Unknown connection error" + } + android.widget.Toast.makeText(context, "AirSync: $msg", android.widget.Toast.LENGTH_LONG).show() } - android.widget.Toast.makeText(context, "AirSync: $msg", android.widget.Toast.LENGTH_LONG).show() } } isConnected.set(false) @@ -419,7 +497,14 @@ 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) + Log.w(TAG, "LAN failure, requested immediate relay fallback: ${t.message}") + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + tryStartAutoReconnect(context) + } else { + Log.d(TAG, "Skipping LAN auto-reconnect: relay already connected/connecting") + } try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -480,15 +565,28 @@ object WebSocketUtil { */ fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) + val type = extractMessageType(message) return if (isSocketOpen.get() && webSocket != null) { - Log.d(TAG, "Sending message: $message") - val messageToSend = currentSymmetricKey?.let { key -> - CryptoUtil.encryptMessage(message, key) - } ?: message + Log.d(TAG, "TX via LAN type=$type") + // SECURITY: Never send plaintext — refuse if no key is available. + val key = currentSymmetricKey + if (key == null) { + Log.e(TAG, "SECURITY: Cannot send LAN message — no symmetric key. Dropping.") + return false + } + val messageToSend = CryptoUtil.encryptMessage(message, key) + if (messageToSend == null) { + Log.e(TAG, "SECURITY: LAN encryption failed. Dropping message.") + return false + } webSocket!!.send(messageToSend) + } else if (AirBridgeClient.isRelayActive()) { + // Fallback: route through AirBridge relay if local connection is down + Log.d(TAG, "TX via RELAY type=$type") + AirBridgeClient.sendMessage(message) } else { - Log.w(TAG, "WebSocket not connected, cannot send message") + Log.w(TAG, "Drop TX type=$type: no LAN/relay available") false } } @@ -649,6 +747,10 @@ object WebSocketUtil { // Monitor discovered devices UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect + if (AirBridgeClient.isRelayConnectedOrConnecting()) { + Log.d(TAG, "Auto-reconnect paused: relay connected/connecting") + return@collect + } val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() @@ -716,4 +818,44 @@ object WebSocketUtil { if (isConnected.get() || isConnecting.get()) return tryStartAutoReconnect(context) } + + /** + * Handles an authChallenge message from the Mac LAN server. + * Computes HMAC-SHA256(symmetricKey, nonce) and sends back an authResponse. + */ + private fun handleAuthChallenge(decryptedMessage: String, webSocket: WebSocket) { + try { + val json = org.json.JSONObject(decryptedMessage) + val data = json.optJSONObject("data") ?: return + val nonceHex = data.optString("nonce", "") + if (nonceHex.isEmpty()) { + Log.e(TAG, "authChallenge missing nonce") + return + } + + val key = currentSymmetricKey + if (key == null) { + Log.e(TAG, "No symmetric key for auth challenge response") + webSocket.close(4003, "No symmetric key") + return + } + + val nonceBytes = CryptoUtil.hexToBytes(nonceHex) + val hmacHex = CryptoUtil.hmacSha256(key, nonceBytes) + + val responseJson = """{"type":"authResponse","data":{"hmac":"$hmacHex"}}""" + val encrypted = CryptoUtil.encryptMessage(responseJson, key) + if (encrypted != null) { + webSocket.send(encrypted) + } else { + // SECURITY: Never send auth HMAC in plaintext — close connection instead. + Log.e(TAG, "SECURITY: Failed to encrypt auth response. Closing connection to prevent HMAC leak.") + webSocket.close(4003, "Encryption failure") + return + } + Log.d(TAG, "Sent authResponse for LAN challenge") + } catch (e: Exception) { + Log.e(TAG, "Error handling authChallenge: ${e.message}") + } + } } From 556f7bce737926f3857daf0a1d34dce17fa18fb9 Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sat, 14 Mar 2026 19:02:28 +0100 Subject: [PATCH 02/29] feat: Improved LAN reconnection from relay and connection transport UI - Added `requestLanReconnectFromRelay` to `WebSocketUtil` to attempt re-establishing direct LAN connections when WiFi becomes available while a relay is active. - Updated `AirSyncService` to trigger LAN reconnection attempts upon WiFi availability. - Enhanced `ConnectionStatusCard` with a transport indicator to distinguish between "Local" and "Relay" (AirBridge) connections. - Updated `UiState` and `AirSyncViewModel` to track and display relay connection status. - Improved `WakeupService` HTTP server stability by enabling `SO_REUSEADDR` on the server socket. --- .../sameerasw/airsync/domain/model/UiState.kt | 1 + .../components/cards/ConnectionStatusCard.kt | 91 +++++++++++++++++-- .../viewmodel/AirSyncViewModel.kt | 1 + .../airsync/service/AirSyncService.kt | 8 ++ .../airsync/service/WakeupService.kt | 5 +- .../sameerasw/airsync/utils/WebSocketUtil.kt | 72 +++++++++++++++ 6 files changed, 167 insertions(+), 11 deletions(-) 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 2a53f26b..f3529f6e 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/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index 8d4f47e7..aec21641 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 @@ -8,9 +8,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -130,21 +133,48 @@ fun ConnectionStatusCard( 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 = "via AirBridge relay", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } 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 + ) + } } } } @@ -191,6 +221,47 @@ fun ConnectionStatusCard( modifier = Modifier.weight(1f) ) + // Connection transport indicator (WiFi vs Relay) + if (isConnected) { + Surface( + shape = RoundedCornerShape(12.dp), + color = if (uiState.isRelayConnection) + MaterialTheme.colorScheme.tertiaryContainer + else + MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.padding(end = 8.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = painterResource( + id = if (uiState.isRelayConnection) + com.sameerasw.airsync.R.drawable.rounded_web_24 + else + com.sameerasw.airsync.R.drawable.rounded_android_wifi_3_bar_24 + ), + contentDescription = if (uiState.isRelayConnection) "Relay connection" else "Local connection", + modifier = Modifier.size(16.dp), + tint = if (uiState.isRelayConnection) + MaterialTheme.colorScheme.onTertiaryContainer + else + MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = if (uiState.isRelayConnection) "Relay" else "Local", + style = MaterialTheme.typography.labelSmall, + color = if (uiState.isRelayConnection) + MaterialTheme.colorScheme.onTertiaryContainer + else + MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + if (isConnected) { OutlinedButton(onClick = { HapticUtil.performClick(haptics) 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 5f0fa88f..29a6a9f6 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 @@ -113,6 +113,7 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy( isConnected = unifiedConnected, isConnecting = unifiedConnecting, + isRelayConnection = relayConnected && !lastWebSocketConnected, response = when { unifiedConnected -> "Connected successfully!" unifiedConnecting -> "Connecting..." 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 002e36a5..5735641a 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 @@ -159,6 +160,13 @@ class AirSyncService : Service() { UDPDiscoveryManager.burstBroadcast(applicationContext) WebSocketUtil.requestAutoReconnect(applicationContext) } + // 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.requestLanReconnectFromRelay(applicationContext) + } } } 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 493d6321..e34fc0dd 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/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index 648be562..0619da3f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -819,6 +819,78 @@ object WebSocketUtil { 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) { + if (isConnected.get() || isConnecting.get()) return + Log.i(TAG, "Attempting LAN reconnect while relay is active") + + 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) { + Log.d(TAG, "LAN reconnect from relay skipped: manual=$manual autoEnabled=$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, then attempt connect + UDPDiscoveryManager.burstBroadcast(context) + delay(1500) // 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 + + Log.i(TAG, "LAN reconnect from relay: trying $ips:$port") + connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = targetConnection.symmetricKey, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + Log.i(TAG, "LAN reconnect succeeded — relay stays warm as backup") + CoroutineScope(Dispatchers.IO).launch { + try { + ds.updateNetworkDeviceLastConnected( + targetConnection.deviceName, + System.currentTimeMillis() + ) + } catch (_: Exception) {} + } + } else { + Log.d(TAG, "LAN reconnect from relay failed — staying on relay") + } + } + ) + } else { + Log.d(TAG, "No target connection found for LAN reconnect from relay") + // Fall back to generic auto-reconnect which monitors discovery + tryStartAutoReconnect(context) + } + } catch (e: Exception) { + Log.e(TAG, "Error in requestLanReconnectFromRelay: ${e.message}") + } + } + } + /** * Handles an authChallenge message from the Mac LAN server. * Computes HMAC-SHA256(symmetricKey, nonce) and sends back an authResponse. From 4602eb3a7d70e379ff99bb76c942ba506482521d Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sun, 15 Mar 2026 17:23:57 +0100 Subject: [PATCH 03/29] Remove custom HMAC auth, use v3.0.0 native encryption --- .../sameerasw/airsync/utils/WebSocketUtil.kt | 91 ++----------------- 1 file changed, 6 insertions(+), 85 deletions(-) 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 44c2d6b9..750fb9a8 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -54,15 +54,6 @@ object WebSocketUtil { // Application context for side-effects (notifications/services) when explicit context isn't provided private var appContext: Context? = null - // Handle LAN authentication challenge by responding with HMAC of the nonce using the symmetric key - private fun extractMessageType(message: String): String { - return try { - org.json.JSONObject(message).optString("type", "unknown") - } catch (_: Exception) { - "non_json" - } - } - // Global connection status listeners for UI updates private val connectionStatusListeners = mutableSetOf<(Boolean) -> Unit>() @@ -249,9 +240,10 @@ object WebSocketUtil { isConnected.set(false) isConnecting.set(true) - // Note: performInitialSync is deferred until after LAN auth succeeds. - // The Mac will send an authChallenge first; we respond with HMAC. - // Once authResult(success=true) arrives, we trigger initial sync. + try { + SyncManager.performInitialSync(context) + } catch (_: Exception) { + } handshakeTimeoutJob?.cancel() handshakeTimeoutJob = CoroutineScope(Dispatchers.IO).launch { @@ -301,37 +293,6 @@ object WebSocketUtil { return } - // Handle LAN authentication challenge-response - val msgType = try { - org.json.JSONObject(decryptedMessage).optString("type", "") - } catch (_: Exception) { "" } - - if (msgType == "authChallenge") { - handleAuthChallenge(decryptedMessage, webSocket) - return - } - - if (msgType == "authResult") { - val authData = try { - org.json.JSONObject(decryptedMessage).optJSONObject("data") - } catch (_: Exception) { null } - val success = authData?.optBoolean("success", false) ?: false - if (!success) { - val reason = authData?.optString("reason", "Unknown") ?: "Unknown" - Log.e(TAG, "LAN authentication failed: $reason") - webSocket.close(4003, "Auth failed: $reason") - return - } - Log.i(TAG, "LAN authentication succeeded, starting initial sync") - // Auth passed — now safe to send device info / initial sync - try { - SyncManager.performInitialSync(context) - } catch (_: Exception) { - } - // Continue — next message should be macInfo for handshake - return - } - if (!handshakeCompleted.get()) { val handshakeOk = try { val json = org.json.JSONObject(decryptedMessage) @@ -567,9 +528,7 @@ object WebSocketUtil { */ fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) - val type = extractMessageType(message) return if (isSocketOpen.get() && webSocket != null) { - Log.d(TAG, "TX via LAN type=$type") // SECURITY: Never send plaintext — refuse if no key is available. val key = currentSymmetricKey if (key == null) { @@ -585,10 +544,10 @@ object WebSocketUtil { webSocket!!.send(messageToSend) } else if (AirBridgeClient.isRelayActive()) { // Fallback: route through AirBridge relay if local connection is down - Log.d(TAG, "TX via RELAY type=$type") + Log.d(TAG, "TX via RELAY") AirBridgeClient.sendMessage(message) } else { - Log.w(TAG, "Drop TX type=$type: no LAN/relay available") + Log.w(TAG, "Drop TX: no LAN/relay available") false } } @@ -893,43 +852,5 @@ object WebSocketUtil { } } - /** - * Handles an authChallenge message from the Mac LAN server. - * Computes HMAC-SHA256(symmetricKey, nonce) and sends back an authResponse. - */ - private fun handleAuthChallenge(decryptedMessage: String, webSocket: WebSocket) { - try { - val json = org.json.JSONObject(decryptedMessage) - val data = json.optJSONObject("data") ?: return - val nonceHex = data.optString("nonce", "") - if (nonceHex.isEmpty()) { - Log.e(TAG, "authChallenge missing nonce") - return - } - - val key = currentSymmetricKey - if (key == null) { - Log.e(TAG, "No symmetric key for auth challenge response") - webSocket.close(4003, "No symmetric key") - return - } - val nonceBytes = CryptoUtil.hexToBytes(nonceHex) - val hmacHex = CryptoUtil.hmacSha256(key, nonceBytes) - - val responseJson = """{"type":"authResponse","data":{"hmac":"$hmacHex"}}""" - val encrypted = CryptoUtil.encryptMessage(responseJson, key) - if (encrypted != null) { - webSocket.send(encrypted) - } else { - // SECURITY: Never send auth HMAC in plaintext — close connection instead. - Log.e(TAG, "SECURITY: Failed to encrypt auth response. Closing connection to prevent HMAC leak.") - webSocket.close(4003, "Encryption failure") - return - } - Log.d(TAG, "Sent authResponse for LAN challenge") - } catch (e: Exception) { - Log.e(TAG, "Error handling authChallenge: ${e.message}") - } - } } From 1bf31c35188389f1abe242b5edb4d03c182099f4 Mon Sep 17 00:00:00 2001 From: tornado-bunk Date: Sun, 15 Mar 2026 19:25:18 +0100 Subject: [PATCH 04/29] feat: WebSocket resilience, lightweight keepalive, and plaintext fallback --- .../airsync/utils/AirBridgeClient.kt | 33 ++++++++++++------- .../airsync/utils/WebSocketMessageHandler.kt | 7 ++-- .../sameerasw/airsync/utils/WebSocketUtil.kt | 28 ++++------------ 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index d5c4afdb..6ffbb24d 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -320,7 +320,7 @@ object AirBridgeClient { .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) - .pingInterval(15, TimeUnit.SECONDS) + .pingInterval(0, TimeUnit.SECONDS) // Disable client-side pings to prevent protocol conflicts .build() } @@ -369,8 +369,9 @@ object AirBridgeClient { } override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "Relay connection failed: ${t.message}") - setState(State.FAILED, "Socket failure: ${t.message}") + val msg = if (t is java.io.EOFException) "Server closed connection (EOF)" else (t.message ?: "Unknown error ($t)") + Log.e(TAG, "Relay connection failed: $msg") + setState(State.FAILED, "Socket failure: $msg") webSocket = null scheduleReconnect(relayUrl, pairingId, secret) } @@ -394,6 +395,7 @@ object AirBridgeClient { 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: ${e.message}") @@ -419,21 +421,28 @@ object AirBridgeClient { } // Decrypt and forward to the existing message handler. - // Never accept plaintext relay messages — refuse if no key is available. + // Try to decrypt first, but allow plaintext fallback for resilience val key = symmetricKey - if (key == null) { - Log.e(TAG, "SECURITY: Cannot decrypt relay message — no symmetric key available. Dropping the message.") - return + var processedMessage: String? = null + + if (key != null) { + processedMessage = CryptoUtil.decryptMessage(text, key) } - val decrypted = CryptoUtil.decryptMessage(text, key) - if (decrypted == null) { - Log.e(TAG, "SECURITY: Decryption failed for relay message (corrupted, tampered, or replay). Dropping.") - return + 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, decrypted) + onMessageReceived?.invoke(ctx, processedMessage!!) } } 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 12dca51a..97aa2c9c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -394,9 +394,10 @@ 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}") } 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 750fb9a8..37e725c9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -281,17 +281,9 @@ object WebSocketUtil { } override fun onMessage(webSocket: WebSocket, text: String) { - // Never accept plaintext LAN messages — refuse if no key. - val key = currentSymmetricKey - if (key == null) { - Log.e(TAG, "SECURITY: Received LAN message but no symmetric key. Dropping.") - return - } - val decryptedMessage = CryptoUtil.decryptMessage(text, key) - if (decryptedMessage == null) { - Log.e(TAG, "SECURITY: LAN message decryption failed (corrupted/tampered/replay). Dropping.") - return - } + val decryptedMessage = currentSymmetricKey?.let { key -> + CryptoUtil.decryptMessage(text, key) + } ?: text if (!handshakeCompleted.get()) { val handshakeOk = try { @@ -529,17 +521,9 @@ object WebSocketUtil { fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) return if (isSocketOpen.get() && webSocket != null) { - // SECURITY: Never send plaintext — refuse if no key is available. - val key = currentSymmetricKey - if (key == null) { - Log.e(TAG, "SECURITY: Cannot send LAN message — no symmetric key. Dropping.") - return false - } - val messageToSend = CryptoUtil.encryptMessage(message, key) - if (messageToSend == null) { - Log.e(TAG, "SECURITY: LAN encryption failed. Dropping message.") - return false - } + val messageToSend = currentSymmetricKey?.let { key -> + CryptoUtil.encryptMessage(message, key) + } ?: message webSocket!!.send(messageToSend) } else if (AirBridgeClient.isRelayActive()) { From 97c4c9413503363f12934cb596428c1f82f533a5 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 00:10:17 +0100 Subject: [PATCH 05/29] refactor: Floating toolbar animations and CryptoUtil cleanup - Simplify `CryptoUtil` by removing `NonceReplayGuard`, HMAC-SHA256, and hex conversion utilities - Update `AirBridgeCard` relay URL placeholder - Remove connection transport indicator (Local/Relay) from `ConnectionStatusCard` --- .../ui/components/AirSyncFloatingToolbar.kt | 2 +- .../ui/components/cards/AirBridgeCard.kt | 2 +- .../components/cards/ConnectionStatusCard.kt | 41 ------ .../ui/screens/AirSyncMainScreen.kt | 134 +++++++++++------- .../com/sameerasw/airsync/utils/CryptoUtil.kt | 113 +-------------- 5 files changed, 85 insertions(+), 207 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt index c10fdac1..952ecbe8 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt @@ -93,7 +93,7 @@ fun AirSyncFloatingToolbar( ), label = "spacer_width_$index" ) - + // Always render the button, but animate its visibility if (itemWidth > 0.dp || isSelected) { IconButton( 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 index a6bd810b..5e5c1a82 100644 --- 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 @@ -162,7 +162,7 @@ fun AirBridgeCard(context: Context) { value = relayUrl, onValueChange = { relayUrl = it }, label = { Text("Relay Server URL") }, - placeholder = { Text("wss://airbridge") }, + placeholder = { Text("airbridge.yourdomain.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true ) 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 12c1de65..cb5ade38 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 @@ -236,47 +236,6 @@ fun ConnectionStatusCard( modifier = Modifier.weight(1f) ) - // Connection transport indicator (WiFi vs Relay) - if (isConnected) { - Surface( - shape = RoundedCornerShape(12.dp), - color = if (uiState.isRelayConnection) - MaterialTheme.colorScheme.tertiaryContainer - else - MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.padding(end = 8.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - painter = painterResource( - id = if (uiState.isRelayConnection) - com.sameerasw.airsync.R.drawable.rounded_web_24 - else - com.sameerasw.airsync.R.drawable.rounded_android_wifi_3_bar_24 - ), - contentDescription = if (uiState.isRelayConnection) "Relay connection" else "Local connection", - modifier = Modifier.size(16.dp), - tint = if (uiState.isRelayConnection) - MaterialTheme.colorScheme.onTertiaryContainer - else - MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = if (uiState.isRelayConnection) "Relay" else "Local", - style = MaterialTheme.typography.labelSmall, - color = if (uiState.isRelayConnection) - MaterialTheme.colorScheme.onTertiaryContainer - else - MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } - if (isConnected) { Button( 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 d90db9e8..1dfce499 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 @@ -32,6 +32,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -57,11 +59,14 @@ import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults +import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset +import androidx.compose.material3.FloatingToolbarExitDirection.Companion.Bottom import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -82,6 +87,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -218,6 +224,8 @@ fun AirSyncMainScreen( val navCallbackState = rememberUpdatedState(onNavigateToApps) LaunchedEffect(navCallbackState.value) { } + var fabVisible by remember { mutableStateOf(true) } + var fabExpanded by remember { mutableStateOf(true) } var showKeyboard by remember { mutableStateOf(false) } // State for Keyboard Sheet in Remote Tab var showHelpSheet by remember { mutableStateOf(false) } val onDismissHelp = { showHelpSheet = false } @@ -596,13 +604,17 @@ fun AirSyncMainScreen( val last = state.value snapshotFlow { state.value }.collect { value -> val delta = value - last + if (delta > 2) fabVisible = false + else if (delta < -2) fabVisible = true } } // Expand FAB on first launch and whenever variant changes (connect <-> disconnect), then collapse after 5s LaunchedEffect(uiState.isConnected) { + fabExpanded = true // Give users a hint for a short period, then collapse to icon-only delay(5000) + fabExpanded = false } // Start/stop clipboard sync based on connection status and settings @@ -1222,35 +1234,42 @@ fun AirSyncMainScreen( ) } - AirSyncFloatingToolbar( - modifier = Modifier.zIndex(1f), - currentPage = pagerState.currentPage, - tabs = tabs, - onTabSelected = { index -> - scope.launch { - val distance = kotlin.math.abs(index - pagerState.currentPage) - if (distance == 1) { - pagerState.animateScrollToPage(index) - } else { - pagerState.scrollToPage(index) - } - } - }, - floatingActionButton = { - MainFAB( - currentTab = tabs.getOrNull(pagerState.currentPage), - isConnected = uiState.isConnected, - onAction = { action -> - when (action) { - "keyboard" -> showKeyboard = !showKeyboard - "clear_history" -> viewModel.clearClipboardHistory() - "disconnect" -> disconnect() - "scan" -> launchScanner(context) + AnimatedVisibility( + visible = fabVisible, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + ) { + AirSyncFloatingToolbar( + modifier = Modifier.zIndex(1f), + currentPage = pagerState.currentPage, + tabs = tabs, + expanded = fabExpanded, + onTabSelected = { index -> + scope.launch { + val distance = kotlin.math.abs(index - pagerState.currentPage) + if (distance == 1) { + pagerState.animateScrollToPage(index) + } else { + pagerState.scrollToPage(index) } } - ) - } - ) + }, + floatingActionButton = { + MainFAB( + currentTab = tabs.getOrNull(pagerState.currentPage), + isConnected = uiState.isConnected, + onAction = { action -> + when (action) { + "keyboard" -> showKeyboard = !showKeyboard + "clear_history" -> viewModel.clearClipboardHistory() + "disconnect" -> disconnect() + "scan" -> launchScanner(context) + } + } + ) + } + ) + } } } else { // Portrait: Stacked @@ -1281,35 +1300,42 @@ fun AirSyncMainScreen( ) } - AirSyncFloatingToolbar( - modifier = Modifier.zIndex(1f), - currentPage = pagerState.currentPage, - tabs = tabs, - onTabSelected = { index -> - scope.launch { - val distance = kotlin.math.abs(index - pagerState.currentPage) - if (distance == 1) { - pagerState.animateScrollToPage(index) - } else { - pagerState.scrollToPage(index) - } - } - }, - floatingActionButton = { - MainFAB( - currentTab = tabs.getOrNull(pagerState.currentPage), - isConnected = uiState.isConnected, - onAction = { action -> - when (action) { - "keyboard" -> showKeyboard = !showKeyboard - "clear_history" -> viewModel.clearClipboardHistory() - "disconnect" -> disconnect() - "scan" -> launchScanner(context) + AnimatedVisibility( + visible = fabVisible, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom), + ) { + AirSyncFloatingToolbar( + modifier = Modifier.zIndex(1f), + currentPage = pagerState.currentPage, + tabs = tabs, + expanded = fabExpanded, + onTabSelected = { index -> + scope.launch { + val distance = kotlin.math.abs(index - pagerState.currentPage) + if (distance == 1) { + pagerState.animateScrollToPage(index) + } else { + pagerState.scrollToPage(index) } } - ) - } - ) + }, + floatingActionButton = { + MainFAB( + currentTab = tabs.getOrNull(pagerState.currentPage), + isConnected = uiState.isConnected, + onAction = { action -> + when (action) { + "keyboard" -> showKeyboard = !showKeyboard + "clear_history" -> viewModel.clearClipboardHistory() + "disconnect" -> disconnect() + "scan" -> launchScanner(context) + } + } + ) + } + ) + } } } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt index a00bbc85..afc136c4 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/CryptoUtil.kt @@ -1,89 +1,22 @@ package com.sameerasw.airsync.utils import android.util.Base64 -import android.util.Log -import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.security.SecureRandom -import java.util.LinkedList import javax.crypto.Cipher -import javax.crypto.Mac import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec -/** - * Thread-safe nonce replay cache to prevent replay attacks on AES-GCM messages. - * Tracks recently-seen 12-byte nonces and rejects duplicates. - */ -private object NonceReplayGuard { - private const val MAX_ENTRIES = 10_000 - private val lock = Any() - private val seenNonces = HashSet(MAX_ENTRIES) - private val nonceOrder = LinkedList() - - /** - * Returns `true` if the nonce has NOT been seen before (message is fresh). - * Returns `false` if the nonce is a duplicate (replay detected). - */ - fun checkAndRecord(nonce: ByteArray): Boolean { - val key = ByteBuffer.wrap(nonce.copyOf()) - synchronized(lock) { - if (seenNonces.contains(key)) { - return false // replay - } - seenNonces.add(key) - nonceOrder.addLast(key) - // Evict oldest entries when cache is full - if (nonceOrder.size > MAX_ENTRIES) { - val evict = nonceOrder.removeFirst() - seenNonces.remove(evict) - } - return true - } - } - - /** Clears the replay cache (e.g. on key rotation or reconnect). */ - fun reset() { - synchronized(lock) { - seenNonces.clear() - nonceOrder.clear() - } - } -} - object CryptoUtil { - private const val TAG = "CryptoUtil" private const val AES_GCM_NOPADDING = "AES/GCM/NoPadding" private const val NONCE_SIZE_BYTES = 12 private const val TAG_SIZE_BITS = 128 - fun decodeKey(base64Key: String): SecretKey? { - return try { - // Accept standard and URL-safe Base64 variants. - var normalized = base64Key.trim() - .replace(" ", "+") - .replace("-", "+") - .replace("_", "/") - .replace("\n", "") - .replace("\r", "") - - // Ensure valid padding length for Base64 decoder. - val pad = normalized.length % 4 - if (pad != 0) { - normalized += "=".repeat(4 - pad) - } - - val keyBytes = Base64.decode(normalized, Base64.DEFAULT) - if (keyBytes.isEmpty()) { - null - } else { - SecretKeySpec(keyBytes, "AES") - } - } catch (_: IllegalArgumentException) { - null - } + fun decodeKey(base64Key: String): SecretKey { + val keyBytes = Base64.decode(base64Key, Base64.DEFAULT) + return SecretKeySpec(keyBytes, "AES") } fun decryptMessage(base64Combined: String, key: SecretKey): String? { @@ -95,13 +28,6 @@ object CryptoUtil { } val nonce = combined.copyOfRange(0, NONCE_SIZE_BYTES) - - // Anti-replay: check that this nonce hasn't been used before - if (!NonceReplayGuard.checkAndRecord(nonce)) { - Log.w(TAG, "Replay detected: duplicate nonce, dropping message") - return null - } - val ciphertextWithTag = combined.copyOfRange(NONCE_SIZE_BYTES, combined.size) val cipher = Cipher.getInstance(AES_GCM_NOPADDING) @@ -134,38 +60,5 @@ object CryptoUtil { null } } - - /** - * Computes HMAC-SHA256 over the given data using the raw bytes of the provided key. - * Returns the result as a lowercase hex string. - */ - fun hmacSha256(key: SecretKey, data: ByteArray): String { - val mac = Mac.getInstance("HmacSHA256") - val hmacKey = SecretKeySpec(key.encoded, "HmacSHA256") - mac.init(hmacKey) - val result = mac.doFinal(data) - return result.joinToString("") { "%02x".format(it) } - } - - /** - * Decodes a hex string into a byte array. - */ - fun hexToBytes(hex: String): ByteArray { - val len = hex.length - val data = ByteArray(len / 2) - var i = 0 - while (i < len) { - data[i / 2] = ((Character.digit(hex[i], 16) shl 4) + Character.digit(hex[i + 1], 16)).toByte() - i += 2 - } - return data - } - - /** - * Resets the replay nonce cache (call on key rotation or reconnect). - */ - fun resetReplayGuard() { - NonceReplayGuard.reset() - } } From 1cb46edad93b92d443747a6266a77692bbd03956 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 00:26:15 +0100 Subject: [PATCH 06/29] feat: Externalize expanded state in AirSyncFloatingToolbar --- .../presentation/ui/components/AirSyncFloatingToolbar.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt index 952ecbe8..15fb92b2 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt @@ -25,9 +25,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -40,13 +37,11 @@ fun AirSyncFloatingToolbar( modifier: Modifier = Modifier, currentPage: Int, tabs: List, + expanded: Boolean = true, onTabSelected: (Int) -> Unit, scrollBehavior: FloatingToolbarScrollBehavior? = null, floatingActionButton: @Composable () -> Unit = {} ) { - // Persistent visibility - var expanded by remember { mutableStateOf(true) } - HorizontalFloatingToolbar( // modifier = modifier // .windowInsetsPadding( From cdaee74475196d43fdd9c52493621679f89c531f Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 00:41:33 +0100 Subject: [PATCH 07/29] fix: no floatingdock --- .../ui/components/AirSyncFloatingToolbar.kt | 9 +++++++-- .../presentation/ui/screens/AirSyncMainScreen.kt | 14 ++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt index 15fb92b2..c10fdac1 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt @@ -25,6 +25,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -37,11 +40,13 @@ fun AirSyncFloatingToolbar( modifier: Modifier = Modifier, currentPage: Int, tabs: List, - expanded: Boolean = true, onTabSelected: (Int) -> Unit, scrollBehavior: FloatingToolbarScrollBehavior? = null, floatingActionButton: @Composable () -> Unit = {} ) { + // Persistent visibility + var expanded by remember { mutableStateOf(true) } + HorizontalFloatingToolbar( // modifier = modifier // .windowInsetsPadding( @@ -88,7 +93,7 @@ fun AirSyncFloatingToolbar( ), label = "spacer_width_$index" ) - + // Always render the button, but animate its visibility if (itemWidth > 0.dp || isSelected) { IconButton( 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 1dfce499..2007a80b 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 @@ -610,12 +610,12 @@ fun AirSyncMainScreen( } // Expand FAB on first launch and whenever variant changes (connect <-> disconnect), then collapse after 5s - LaunchedEffect(uiState.isConnected) { - fabExpanded = true - // Give users a hint for a short period, then collapse to icon-only - delay(5000) - fabExpanded = false - } + // LaunchedEffect(uiState.isConnected) { + // fabExpanded = true + // // Give users a hint for a short period, then collapse to icon-only + // delay(5000) + // fabExpanded = false + // } // Start/stop clipboard sync based on connection status and settings LaunchedEffect(uiState.isConnected, uiState.isClipboardSyncEnabled) { @@ -1243,7 +1243,6 @@ fun AirSyncMainScreen( modifier = Modifier.zIndex(1f), currentPage = pagerState.currentPage, tabs = tabs, - expanded = fabExpanded, onTabSelected = { index -> scope.launch { val distance = kotlin.math.abs(index - pagerState.currentPage) @@ -1309,7 +1308,6 @@ fun AirSyncMainScreen( modifier = Modifier.zIndex(1f), currentPage = pagerState.currentPage, tabs = tabs, - expanded = fabExpanded, onTabSelected = { index -> scope.launch { val distance = kotlin.math.abs(index - pagerState.currentPage) From f8021fc6f85751ecce92e46e3ea9303007abc6ff Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 00:52:09 +0100 Subject: [PATCH 08/29] fix: FAB visibility logic on scroll --- .../airsync/presentation/ui/screens/AirSyncMainScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 2007a80b..9d339ef5 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 @@ -601,11 +601,12 @@ fun AirSyncMainScreen( // Hide FAB on scroll down, show on scroll up for the active tab LaunchedEffect(pagerState.currentPage) { val state = if (pagerState.currentPage == 0) connectScrollState else settingsScrollState - val last = state.value + var last = state.value snapshotFlow { state.value }.collect { value -> val delta = value - last if (delta > 2) fabVisible = false else if (delta < -2) fabVisible = true + last = value } } From ca241418c1b4a90606a9e22eb0ae14a86557d250 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 01:30:13 +0100 Subject: [PATCH 09/29] feat: improve relay and LAN reconnection logic - Implement periodic LAN reconnection attempts while the relay is active to prefer local paths when available. - Update `AirSyncViewModel` to prevent stopping the service or relay when Wi-Fi is lost if a relay connection is active. - Introduce a connection generation counter in `AirBridgeClient` to prevent race conditions and stale WebSocket callbacks. - Add safeguards against duplicate relay connection attempts. - Ensure the service triggers immediate LAN retries when a relay is active during scanning. - Improved error handling for cancelled discovery coroutines. --- .../viewmodel/AirSyncViewModel.kt | 36 ++++++++++------ .../airsync/service/AirSyncService.kt | 4 ++ .../airsync/utils/AirBridgeClient.kt | 41 ++++++++++++++++--- .../sameerasw/airsync/utils/WebSocketUtil.kt | 17 +++++++- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index aceb156d..3618959f 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 @@ -787,18 +787,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 @@ -883,7 +891,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 5735641a..adad7101 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -159,6 +159,10 @@ 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.requestLanReconnectFromRelay(applicationContext) + } } // When WiFi returns while relay is active but LAN is down, // attempt to re-establish the preferred local connection. diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index 6ffbb24d..bc16ffb4 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -20,6 +20,7 @@ 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.SecretKey /** @@ -66,6 +67,7 @@ object AirBridgeClient { // Prevent concurrent connect attempts to private val connectInProgress = AtomicBoolean(false) + private val activeConnectionGeneration = AtomicLong(0L) // Message callback — routes relayed messages to the existing handler private var onMessageReceived: ((Context, String) -> Unit)? = null @@ -89,6 +91,11 @@ object AirBridgeClient { 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 } @@ -227,6 +234,7 @@ object AirBridgeClient { webSocket?.close(1000, "Manual disconnect") } catch (_: Exception) {} webSocket = null + activeConnectionGeneration.incrementAndGet() client?.dispatcher?.executorService?.shutdown() client = null @@ -307,6 +315,9 @@ object AirBridgeClient { * Internal function to establish WebSocket connection and handle relay protocol. */ private fun connectInternal(relayUrl: String, pairingId: String, secret: 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") @@ -331,8 +342,16 @@ object AirBridgeClient { // 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, send the registration message with the pairing ID and secret hash. override fun onOpen(ws: WebSocket, response: Response) { + if (isStale()) { + try { + ws.close(1000, "Stale relay socket") + } catch (_: Exception) {} + return + } Log.d(TAG, "WebSocket opened to relay") webSocket = ws setState(State.REGISTERING, "Socket open, sending register") @@ -358,22 +377,30 @@ object AirBridgeClient { } override fun onMessage(ws: WebSocket, text: String) { + if (isStale()) return handleTextMessage(text) } override fun onClosing(ws: WebSocket, code: Int, reason: String) { + if (isStale()) return Log.d(TAG, "Relay closing: $code $reason") ws.close(1000, null) setState(State.DISCONNECTED, "Socket closing code=$code") - scheduleReconnect(relayUrl, pairingId, secret) + if (webSocket == ws) { + webSocket = null + } + scheduleReconnect(relayUrl, pairingId, secret, generation) } override fun onFailure(ws: 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: $msg") setState(State.FAILED, "Socket failure: $msg") - webSocket = null - scheduleReconnect(relayUrl, pairingId, secret) + if (webSocket == ws) { + webSocket = null + } + scheduleReconnect(relayUrl, pairingId, secret, generation) } } @@ -390,6 +417,9 @@ object AirBridgeClient { "relay_started" -> { Log.i(TAG, "Relay tunnel established!") setState(State.RELAY_ACTIVE, "Server confirmed relay tunnel") + reconnectJob?.cancel() + reconnectJob = null + reconnectAttempt = 0 // Trigger initial sync via relay now that the tunnel is active appContext?.let { ctx -> @@ -447,8 +477,9 @@ object AirBridgeClient { } // 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, secret: String) { + private fun scheduleReconnect(relayUrl: String, pairingId: String, secret: String, sourceGeneration: Long) { if (isManuallyDisconnected.get()) return + if (sourceGeneration != activeConnectionGeneration.get()) return val delayMs = minOf( (1L shl minOf(reconnectAttempt, 10)) * 1000L, @@ -462,7 +493,7 @@ object AirBridgeClient { reconnectJob?.cancel() reconnectJob = CoroutineScope(Dispatchers.IO).launch { delay(delayMs) - if (!isManuallyDisconnected.get()) { + if (!isManuallyDisconnected.get() && sourceGeneration == activeConnectionGeneration.get()) { connectInternal(relayUrl, pairingId, secret) } } 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 37e725c9..efd197b6 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -16,6 +16,7 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong /** * Singleton utility for managing the WebSocket connection to the AirSync Mac server. @@ -46,6 +47,7 @@ object WebSocketUtil { private var autoReconnectActive = AtomicBoolean(false) private var autoReconnectStartTime: Long = 0L private var autoReconnectAttempts: Int = 0 + private val lastRelayLanRetryMs = AtomicLong(0L) // Callback for connection status changes private var onConnectionStatusChanged: ((Boolean) -> Unit)? = null @@ -693,7 +695,14 @@ object WebSocketUtil { UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect if (AirBridgeClient.isRelayConnectedOrConnecting()) { - Log.d(TAG, "Auto-reconnect paused: relay connected/connecting") + val now = System.currentTimeMillis() + val last = lastRelayLanRetryMs.get() + if (now - last >= 10_000L && lastRelayLanRetryMs.compareAndSet(last, now)) { + Log.d(TAG, "Relay active: trying LAN reconnect from relay path") + requestLanReconnectFromRelay(context) + } else { + Log.d(TAG, "Auto-reconnect paused: relay connected/connecting") + } return@collect } @@ -752,7 +761,11 @@ object WebSocketUtil { } } } catch (e: Exception) { - Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") + if (e is kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Discovery auto-reconnect cancelled") + } else { + Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") + } } } } From ae9228099a727f82245293ac752911a7b46d643f Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 15:37:00 +0100 Subject: [PATCH 10/29] feat: Added peer status monitoring and Mac wake handling for AirBridge - Implemented `peerReallyActive` state tracking in `AirBridgeClient` using periodic `query_status` polling. - Added a "Peer offline" indicator to the `AirBridgeCard` UI when the relay is active but the peer is disconnected. - Added a `macWake` message handler to trigger LAN reconnection requests via the relay. - Improved cleanup of connection states and polling jobs during disconnection. --- .../ui/components/cards/AirBridgeCard.kt | 19 ++++++ .../airsync/utils/AirBridgeClient.kt | 65 +++++++++++++++++++ .../airsync/utils/WebSocketMessageHandler.kt | 16 +++++ 3 files changed, 100 insertions(+) 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 index 5e5c1a82..8844f4b8 100644 --- 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 @@ -63,6 +63,7 @@ fun AirBridgeCard(context: Context) { 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 } } @@ -155,6 +156,24 @@ fun AirBridgeCard(context: Context) { ) } + 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 diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index bc16ffb4..b490af2d 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -58,6 +58,7 @@ object AirBridgeClient { // 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 @@ -69,6 +70,12 @@ object AirBridgeClient { 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 @@ -81,6 +88,9 @@ object AirBridgeClient { Log.d(TAG, "State unchanged: $newState | $reason") } _connectionState.value = newState + if (newState != State.RELAY_ACTIVE) { + _peerReallyActive.value = false + } } /** @@ -227,7 +237,11 @@ object AirBridgeClient { isManuallyDisconnected.set(true) reconnectJob?.cancel() reconnectJob = null + statusQueryJob?.cancel() + statusQueryJob = null reconnectAttempt = 0 + lastStatusBothConnected = false + _peerReallyActive.value = false // Close WebSocket connection gracefully. try { @@ -290,6 +304,12 @@ object AirBridgeClient { */ fun isRelayActive(): Boolean = _connectionState.value == State.RELAY_ACTIVE + /** + * Returns true if the relay reports that both Mac and Android are currently attached + * to the same pairing session (based on the latest status_reply snapshot). + */ + fun isPeerReallyActive(): Boolean = isRelayActive() && lastStatusBothConnected + /** * Returns true if relay transport is already usable or being established. * Useful to suppress noisy LAN reconnect loops while relay failover is in progress. @@ -370,6 +390,9 @@ object AirBridgeClient { if (ws.send(regMsg.toString())) { Log.d(TAG, "Registration sent for pairingId: $pairingId") 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") @@ -386,6 +409,10 @@ object AirBridgeClient { Log.d(TAG, "Relay closing: $code $reason") ws.close(1000, null) setState(State.DISCONNECTED, "Socket closing code=$code") + statusQueryJob?.cancel() + statusQueryJob = null + lastStatusBothConnected = false + _peerReallyActive.value = false if (webSocket == ws) { webSocket = null } @@ -397,6 +424,10 @@ object AirBridgeClient { val msg = if (t is java.io.EOFException) "Server closed connection (EOF)" else (t.message ?: "Unknown error ($t)") Log.e(TAG, "Relay connection failed: $msg") setState(State.FAILED, "Socket failure: $msg") + statusQueryJob?.cancel() + statusQueryJob = null + lastStatusBothConnected = false + _peerReallyActive.value = false if (webSocket == ws) { webSocket = null } @@ -420,6 +451,7 @@ object AirBridgeClient { reconnectJob?.cancel() reconnectJob = null reconnectAttempt = 0 + startStatusPolling() // Trigger initial sync via relay now that the tunnel is active appContext?.let { ctx -> @@ -434,6 +466,16 @@ object AirBridgeClient { } 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 + Log.d(TAG, "Relay status_reply: macActive=$macActive androidActive=$androidActive bothConnected=$both") + 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 @@ -499,6 +541,29 @@ object AirBridgeClient { } } + // 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) { + Log.d(TAG, "query_status send failed: ${e.message}") + } + } + /** * SHA-256 hashes the raw secret so the plaintext never leaves the device. * The relay server only ever sees (and stores) this hash. 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 97aa2c9c..82e1c23f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -83,6 +83,7 @@ object WebSocketMessageHandler { "modifierStatus" -> handleModifierStatus(data) "ping" -> handlePing(context) "status" -> handleMacDeviceStatus(context, data) + "macWake" -> handleMacWake(context) "macInfo" -> handleMacInfo(context, data) "refreshAdbPorts" -> handleRefreshAdbPorts(context) "browseLs" -> handleBrowseLs(context, data) @@ -403,6 +404,21 @@ object WebSocketMessageHandler { } } + private fun handleMacWake(context: Context) { + try { + Log.d(TAG, "Received macWake via relay – requesting LAN reconnect from relay") + CoroutineScope(Dispatchers.IO).launch { + try { + WebSocketUtil.requestLanReconnectFromRelay(context) + } catch (e: Exception) { + Log.e(TAG, "Error requesting LAN reconnect from relay: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error handling macWake: ${e.message}") + } + } + private fun handleDisconnectRequest(context: Context) { try { // Mark as intentional disconnect to prevent auto-reconnect From c1a00fb23965463c2243e074acc409c4fbfc0a87 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 18:07:36 +0100 Subject: [PATCH 11/29] feat: Peer health status badge --- .../components/cards/ConnectionStatusCard.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 cb5ade38..cb754e03 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 @@ -30,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 @@ -44,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 @@ -138,6 +141,8 @@ fun ConnectionStatusCard( } } + val peerReallyActive by AirBridgeClient.peerReallyActive.collectAsState() + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -168,6 +173,21 @@ fun ConnectionStatusCard( ) } } + + // Peer health badge when connected via relay + Surface( + shape = RoundedCornerShape(12.dp), + color = if (peerReallyActive) Color(0xFF4CAF50).copy(alpha = 0.16f) else Color( + 0xFFFF9800 + ).copy(alpha = 0.16f) + ) { + Text( + text = if (peerReallyActive) "Peer online" else "Peer offline", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = if (peerReallyActive) Color(0xFF4CAF50) else Color(0xFFFF9800) + ) + } } else { val ips = uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } From b5eec36d1780733ae4478ce188b410cf5ea7661d Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 18:26:15 +0100 Subject: [PATCH 12/29] feat: Peer transport notification for seamless UI switching - Added `notifyPeerTransportChanged` to `WebSocketUtil` to inform the peer (desktop) about active transport changes (Wi-Fi/LAN vs. Relay). - Implemented transport status advertisements during LAN handshake completion and relay connection establishment. - Added `peerTransport` message type handling in `WebSocketMessageHandler`. - Reset advertised transport state upon manual disconnection. --- .../airsync/utils/AirBridgeClient.kt | 1 + .../airsync/utils/WebSocketMessageHandler.kt | 11 ++++++ .../sameerasw/airsync/utils/WebSocketUtil.kt | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index b490af2d..820813dc 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -452,6 +452,7 @@ object AirBridgeClient { reconnectJob = null reconnectAttempt = 0 startStatusPolling() + WebSocketUtil.notifyPeerTransportChanged("relay", force = true) // Trigger initial sync via relay now that the tunnel is active appContext?.let { ctx -> 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 82e1c23f..19bc8f15 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -84,6 +84,7 @@ object WebSocketMessageHandler { "ping" -> handlePing(context) "status" -> handleMacDeviceStatus(context, data) "macWake" -> handleMacWake(context) + "peerTransport" -> handlePeerTransport(data) "macInfo" -> handleMacInfo(context, data) "refreshAdbPorts" -> handleRefreshAdbPorts(context) "browseLs" -> handleBrowseLs(context, data) @@ -419,6 +420,16 @@ object WebSocketMessageHandler { } } + private fun handlePeerTransport(data: JSONObject?) { + try { + val transport = data?.optString("transport", "unknown") ?: "unknown" + val source = data?.optString("source", "peer") ?: "peer" + Log.d(TAG, "Peer transport update received: source=$source transport=$transport") + } catch (e: Exception) { + Log.e(TAG, "Error handling peerTransport: ${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 efd197b6..6bb3e531 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -14,6 +14,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import org.json.JSONObject import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -48,6 +49,7 @@ object WebSocketUtil { private var autoReconnectStartTime: Long = 0L private var autoReconnectAttempts: Int = 0 private val lastRelayLanRetryMs = AtomicLong(0L) + private val lastAdvertisedTransport = java.util.concurrent.atomic.AtomicReference(null) // Callback for connection status changes private var onConnectionStatusChanged: ((Boolean) -> Unit)? = null @@ -58,6 +60,34 @@ 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 { + if (!force && lastAdvertisedTransport.get() == 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 + } + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() @@ -344,6 +374,7 @@ object WebSocketUtil { cancelAutoReconnect() // Keep relay warm in background (if enabled) for instant failover. AirBridgeClient.ensureConnected(context, immediate = false) + notifyPeerTransportChanged("wifi", force = true) Log.i(TAG, "LAN handshake completed on $ip:$port, relay kept warm") try { AirSyncWidgetProvider.updateAllWidgets(context) @@ -395,6 +426,7 @@ object WebSocketUtil { notifyConnectionStatusListeners(false) // If relay is enabled, force immediate relay reconnect for seamless fallback. AirBridgeClient.ensureConnected(context, immediate = true) + notifyPeerTransportChanged("relay", force = true) Log.w(TAG, "LAN socket closing, requested immediate relay fallback") if (!AirBridgeClient.isRelayConnectedOrConnecting()) { tryStartAutoReconnect(context) @@ -456,6 +488,7 @@ object WebSocketUtil { notifyConnectionStatusListeners(false) // If relay is enabled, force immediate relay reconnect for seamless fallback. AirBridgeClient.ensureConnected(context, immediate = true) + notifyPeerTransportChanged("relay", force = true) Log.w(TAG, "LAN failure, requested immediate relay fallback: ${t.message}") if (!AirBridgeClient.isRelayConnectedOrConnecting()) { tryStartAutoReconnect(context) @@ -553,6 +586,7 @@ object WebSocketUtil { // Stop periodic sync when disconnecting SyncManager.stopPeriodicSync() + lastAdvertisedTransport.set(null) webSocket?.close(1000, "Manual disconnection") webSocket = null From 6751f10e9904a468eb59bf4ad9318a78afa9231f Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 16 Mar 2026 18:56:13 +0100 Subject: [PATCH 13/29] feat: Improved transport synchronization and QuickShare foreground discovery - Added transport change logging in WebSocketUtil - Dynamic transport notification (WiFi vs Relay) based on connectivity when relay connects - Set ACTION_START_DISCOVERY when starting QuickShareService to ensure proper foreground initialization --- .../airsync/presentation/viewmodel/AirSyncViewModel.kt | 2 ++ .../com/sameerasw/airsync/utils/AirBridgeClient.kt | 5 ++++- .../java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 10 +++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index 3618959f..1b6ab87a 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 @@ -689,6 +689,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 { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index 820813dc..c3f4f6e4 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -452,7 +452,10 @@ object AirBridgeClient { reconnectJob = null reconnectAttempt = 0 startStatusPolling() - WebSocketUtil.notifyPeerTransportChanged("relay", force = true) + // 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) // Trigger initial sync via relay now that the tunnel is active appContext?.let { ctx -> 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 6bb3e531..ec0c125e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -60,9 +60,17 @@ 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 { - if (!force && lastAdvertisedTransport.get() == transport) return true + val previous = lastAdvertisedTransport.get() + if (!force && previous == transport) return true + + Log.d( + TAG, + "transport_sync: direction=android->mac source=android transport_old=${previous ?: "null"} transport_new=$transport force=$force relayActive=${AirBridgeClient.isRelayConnectedOrConnecting()} lanConnected=${isConnected.get()}" + ) + val payload = JSONObject().apply { put("type", "peerTransport") put("data", JSONObject().apply { From 9af2bcc542c1f2ba0513afc3c01f2da3b084ee66 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Tue, 17 Mar 2026 14:50:31 +0100 Subject: [PATCH 14/29] feat: Enhance macWake handling with LAN reconnect retry logic - Updated `handleMacWake` to accept additional data for improved LAN reconnection attempts. - Implemented a retry loop for LAN reconnects to avoid spamming and overlapping jobs. - Added logging for connection attempts and fallback strategies to ensure robust handling of macWake events. --- .../airsync/utils/WebSocketMessageHandler.kt | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) 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 19bc8f15..9e608dd3 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -11,6 +11,7 @@ import com.sameerasw.airsync.data.repository.AirSyncRepositoryImpl import com.sameerasw.airsync.service.MediaNotificationListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -26,6 +27,9 @@ object WebSocketMessageHandler { // Track if we're currently receiving playing media from Mac to prevent feedback loop private var isReceivingPlayingMedia = false + // macWake LAN reconnect retry loop (avoid spamming / overlapping jobs) + private var macWakeLanReconnectJob: Job? = null + // Callback for clipboard entry history tracking private var onClipboardEntryReceived: ((text: String) -> Unit)? = null @@ -83,7 +87,7 @@ object WebSocketMessageHandler { "modifierStatus" -> handleModifierStatus(data) "ping" -> handlePing(context) "status" -> handleMacDeviceStatus(context, data) - "macWake" -> handleMacWake(context) + "macWake" -> handleMacWake(context, data) "peerTransport" -> handlePeerTransport(data) "macInfo" -> handleMacInfo(context, data) "refreshAdbPorts" -> handleRefreshAdbPorts(context) @@ -405,14 +409,64 @@ object WebSocketMessageHandler { } } - private fun handleMacWake(context: Context) { + private fun handleMacWake(context: Context, data: JSONObject?) { try { - Log.d(TAG, "Received macWake via relay – requesting LAN reconnect from relay") - CoroutineScope(Dispatchers.IO).launch { + val ips = data?.optString("ips", "") ?: "" + val port = data?.optInt("port", -1) ?: -1 + val adapter = data?.optString("adapter", "auto") ?: "auto" + + Log.d(TAG, "transport_sync: direction=mac->android type=macWake ips=$ips port=$port adapter=$adapter") + Log.d(TAG, "Received macWake via relay – scheduling LAN reconnect attempts") + + macWakeLanReconnectJob?.cancel() + macWakeLanReconnectJob = CoroutineScope(Dispatchers.IO).launch { try { - WebSocketUtil.requestLanReconnectFromRelay(context) + val ds = DataStoreManager.getInstance(context) + val last = ds.getLastConnectedDevice().first() + val key = last?.symmetricKey + + val canDirectAttempt = ips.isNotBlank() && port > 0 && key != null + val start = System.currentTimeMillis() + val maxWindowMs = 120_000L + val intervalMs = 15_000L + var attempt = 0 + + while (System.currentTimeMillis() - start <= maxWindowMs) { + if (WebSocketUtil.isConnected()) { + Log.d(TAG, "macWake: LAN connected, stopping retry loop") + return@launch + } + if (!WebSocketUtil.isConnecting()) { + if (canDirectAttempt) { + Log.d(TAG, "macWake: LAN attempt #$attempt to $ips:$port") + WebSocketUtil.connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = key, + manualAttempt = false + ) + } else { + // If we can't do a direct connect, at least keep discovery warm. + try { + UDPDiscoveryManager.burstBroadcast(context) + } catch (_: Exception) {} + } + } else { + Log.d(TAG, "macWake: skipping attempt #$attempt (already connecting)") + } + + attempt += 1 + delay(intervalMs) + } + + // After the retry window, fall back to the broader reconnect strategy. + if (!WebSocketUtil.isConnected()) { + Log.d(TAG, "macWake: retry window ended, falling back to requestLanReconnectFromRelay()") + WebSocketUtil.requestLanReconnectFromRelay(context) + } } catch (e: Exception) { - Log.e(TAG, "Error requesting LAN reconnect from relay: ${e.message}") + Log.e(TAG, "Error handling macWake LAN retry loop: ${e.message}") } } } catch (e: Exception) { From 0328afb37075744aed85a4f13b47f8437ab99b34 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 18 Mar 2026 23:32:49 +0100 Subject: [PATCH 15/29] feat: Implement adaptive LAN-first probing for relay connections - Introduced a `startLanFirstRelayProbe` loop in `WebSocketUtil` that periodically attempts to recover direct LAN connections while a relay is active. - Implemented an adaptive polling interval (15s/30s/60s) based on the elapsed time since the probe started to balance responsiveness and power consumption. - Integrated the LAN probe into `AirBridgeClient` lifecycle events, triggering it when a relay starts and stopping it on manual disconnection or socket failure. - Updated `AirSyncService` to initiate LAN probing when the app enters the foreground or when Wi-Fi becomes available. - Refactored `macWake` handling in `WebSocketMessageHandler` to use the new probing logic for more reliable LAN recovery after a wake event. - Enhanced `requestLanReconnectFromRelay` with source tracking and a short-term debounce to prevent overlapping reconnection attempts. --- .../airsync/service/AirSyncService.kt | 22 +++- .../airsync/utils/AirBridgeClient.kt | 13 ++ .../airsync/utils/WebSocketMessageHandler.kt | 68 ++++------- .../sameerasw/airsync/utils/WebSocketUtil.kt | 112 ++++++++++++++++-- 4 files changed, 159 insertions(+), 56 deletions(-) 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 adad7101..29fad8fa 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -107,6 +107,14 @@ class AirSyncService : Service() { UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.ACTIVE) startForeground(NOTIFICATION_ID, buildNotification()) // Update notification if needed } + if (!WebSocketUtil.isConnected() && AirBridgeClient.isRelayConnectedOrConnecting()) { + WebSocketUtil.startLanFirstRelayProbe( + context = applicationContext, + immediate = true, + source = "app_foreground", + resetBackoff = true + ) + } } private fun handleAppBackground() { @@ -161,7 +169,12 @@ class AirSyncService : Service() { WebSocketUtil.requestAutoReconnect(applicationContext) // If relay is already active, also force a direct LAN retry immediately. if (AirBridgeClient.isRelayConnectedOrConnecting()) { - WebSocketUtil.requestLanReconnectFromRelay(applicationContext) + WebSocketUtil.startLanFirstRelayProbe( + context = applicationContext, + immediate = true, + source = "network_onAvailable_scanning", + resetBackoff = true + ) } } // When WiFi returns while relay is active but LAN is down, @@ -169,7 +182,12 @@ class AirSyncService : Service() { if (!isScanning && !WebSocketUtil.isConnected() && AirBridgeClient.isRelayActive()) { Log.i(TAG, "WiFi available while relay is active — attempting LAN reconnect") UDPDiscoveryManager.burstBroadcast(applicationContext) - WebSocketUtil.requestLanReconnectFromRelay(applicationContext) + WebSocketUtil.startLanFirstRelayProbe( + context = applicationContext, + immediate = true, + source = "network_onAvailable_sync", + resetBackoff = true + ) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index c3f4f6e4..18a7e5f9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -237,6 +237,7 @@ object AirBridgeClient { isManuallyDisconnected.set(true) reconnectJob?.cancel() reconnectJob = null + WebSocketUtil.stopLanFirstRelayProbe("relay_manual_disconnect") statusQueryJob?.cancel() statusQueryJob = null reconnectAttempt = 0 @@ -409,6 +410,7 @@ object AirBridgeClient { Log.d(TAG, "Relay closing: $code $reason") ws.close(1000, null) setState(State.DISCONNECTED, "Socket closing code=$code") + WebSocketUtil.stopLanFirstRelayProbe("relay_onClosing") statusQueryJob?.cancel() statusQueryJob = null lastStatusBothConnected = false @@ -424,6 +426,7 @@ object AirBridgeClient { val msg = if (t is java.io.EOFException) "Server closed connection (EOF)" else (t.message ?: "Unknown error ($t)") Log.e(TAG, "Relay connection failed: $msg") setState(State.FAILED, "Socket failure: $msg") + WebSocketUtil.stopLanFirstRelayProbe("relay_onFailure") statusQueryJob?.cancel() statusQueryJob = null lastStatusBothConnected = false @@ -456,6 +459,16 @@ object AirBridgeClient { // 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()) { + 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 -> 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 9e608dd3..b7f2cc68 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -11,7 +11,6 @@ import com.sameerasw.airsync.data.repository.AirSyncRepositoryImpl import com.sameerasw.airsync.service.MediaNotificationListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -27,9 +26,6 @@ object WebSocketMessageHandler { // Track if we're currently receiving playing media from Mac to prevent feedback loop private var isReceivingPlayingMedia = false - // macWake LAN reconnect retry loop (avoid spamming / overlapping jobs) - private var macWakeLanReconnectJob: Job? = null - // Callback for clipboard entry history tracking private var onClipboardEntryReceived: ((text: String) -> Unit)? = null @@ -416,57 +412,41 @@ object WebSocketMessageHandler { val adapter = data?.optString("adapter", "auto") ?: "auto" Log.d(TAG, "transport_sync: direction=mac->android type=macWake ips=$ips port=$port adapter=$adapter") - Log.d(TAG, "Received macWake via relay – scheduling LAN reconnect attempts") + Log.d(TAG, "Received macWake via relay – attempting immediate LAN and enabling LAN-first probe") - macWakeLanReconnectJob?.cancel() - macWakeLanReconnectJob = CoroutineScope(Dispatchers.IO).launch { + CoroutineScope(Dispatchers.IO).launch { try { val ds = DataStoreManager.getInstance(context) val last = ds.getLastConnectedDevice().first() val key = last?.symmetricKey - val canDirectAttempt = ips.isNotBlank() && port > 0 && key != null - val start = System.currentTimeMillis() - val maxWindowMs = 120_000L - val intervalMs = 15_000L - var attempt = 0 - - while (System.currentTimeMillis() - start <= maxWindowMs) { - if (WebSocketUtil.isConnected()) { - Log.d(TAG, "macWake: LAN connected, stopping retry loop") - return@launch - } - if (!WebSocketUtil.isConnecting()) { - if (canDirectAttempt) { - Log.d(TAG, "macWake: LAN attempt #$attempt to $ips:$port") - WebSocketUtil.connect( - context = context, - ipAddress = ips, - port = port, - symmetricKey = key, - manualAttempt = false - ) - } else { - // If we can't do a direct connect, at least keep discovery warm. - try { - UDPDiscoveryManager.burstBroadcast(context) - } catch (_: Exception) {} - } + if (!WebSocketUtil.isConnected() && !WebSocketUtil.isConnecting()) { + if (ips.isNotBlank() && port > 0 && key != null) { + Log.d(TAG, "macWake: immediate LAN attempt to $ips:$port") + WebSocketUtil.connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = key, + manualAttempt = false + ) + delay(1200) } else { - Log.d(TAG, "macWake: skipping attempt #$attempt (already connecting)") + try { + UDPDiscoveryManager.burstBroadcast(context) + } catch (_: Exception) {} } - - attempt += 1 - delay(intervalMs) } - // After the retry window, fall back to the broader reconnect strategy. - if (!WebSocketUtil.isConnected()) { - Log.d(TAG, "macWake: retry window ended, falling back to requestLanReconnectFromRelay()") - WebSocketUtil.requestLanReconnectFromRelay(context) - } + // 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 retry loop: ${e.message}") + Log.e(TAG, "Error handling macWake LAN orchestration: ${e.message}") } } } catch (e: Exception) { 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 ec0c125e..c769eeb5 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -8,6 +8,7 @@ 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 @@ -50,6 +51,14 @@ object WebSocketUtil { 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 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 // Callback for connection status changes private var onConnectionStatusChanged: ((Boolean) -> Unit)? = null @@ -96,6 +105,77 @@ object WebSocketUtil { 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 (!AirBridgeClient.isRelayConnectedOrConnecting()) { + stopLanFirstRelayProbe("relay_not_active") + return + } + + val now = System.currentTimeMillis() + if (relayLanProbeJob?.isActive == true) { + if (resetBackoff) { + relayLanProbeStartedAtMs.set(now) + Log.d(TAG, "LAN-first probe backoff reset (source=$source)") + } + Log.d(TAG, "LAN-first probe already running (source=$source resetBackoff=$resetBackoff)") + if (immediate) { + CoroutineScope(Dispatchers.IO).launch { + requestLanReconnectFromRelay(context, source = "immediate:$source") + } + } + return + } + + relayLanProbeStartedAtMs.set(now) + relayLanProbeJob = CoroutineScope(Dispatchers.IO).launch { + Log.i(TAG, "Starting LAN-first probe loop (source=$source resetBackoff=$resetBackoff)") + if (immediate) { + requestLanReconnectFromRelay(context, source = "start:$source") + } + + while (isActive) { + val elapsed = (System.currentTimeMillis() - relayLanProbeStartedAtMs.get()).coerceAtLeast(0L) + val intervalMs = computeAdaptiveLanProbeInterval(elapsed) + delay(intervalMs) + if (isConnected.get()) { + Log.d(TAG, "Stopping LAN-first probe: LAN is connected") + break + } + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { + Log.d(TAG, "Stopping LAN-first probe: relay not connected/connecting") + 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) + Log.d(TAG, "Stopped LAN-first probe loop (reason=$reason)") + } + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() @@ -380,6 +460,7 @@ 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) @@ -435,6 +516,9 @@ object WebSocketUtil { // 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) @@ -497,6 +581,9 @@ object WebSocketUtil { // 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: ${t.message}") if (!AirBridgeClient.isRelayConnectedOrConnecting()) { tryStartAutoReconnect(context) @@ -595,6 +682,7 @@ object WebSocketUtil { // Stop periodic sync when disconnecting SyncManager.stopPeriodicSync() lastAdvertisedTransport.set(null) + stopLanFirstRelayProbe("manual_disconnect") webSocket?.close(1000, "Manual disconnection") webSocket = null @@ -656,6 +744,7 @@ object WebSocketUtil { onMessageReceived = null handshakeCompleted.set(false) handshakeTimeoutJob?.cancel() + stopLanFirstRelayProbe("cleanup") appContext = null } @@ -737,14 +826,7 @@ object WebSocketUtil { UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect if (AirBridgeClient.isRelayConnectedOrConnecting()) { - val now = System.currentTimeMillis() - val last = lastRelayLanRetryMs.get() - if (now - last >= 10_000L && lastRelayLanRetryMs.compareAndSet(last, now)) { - Log.d(TAG, "Relay active: trying LAN reconnect from relay path") - requestLanReconnectFromRelay(context) - } else { - Log.d(TAG, "Auto-reconnect paused: relay connected/connecting") - } + startLanFirstRelayProbe(context, immediate = false, source = "auto_reconnect_collect", resetBackoff = false) return@collect } @@ -826,8 +908,18 @@ object WebSocketUtil { * prefers LAN via sendMessage(). */ fun requestLanReconnectFromRelay(context: Context) { + requestLanReconnectFromRelay(context, source = "default") + } + + fun requestLanReconnectFromRelay(context: Context, source: String) { if (isConnected.get() || isConnecting.get()) return - Log.i(TAG, "Attempting LAN reconnect while relay is active") + val now = System.currentTimeMillis() + val last = lastRelayLanRetryMs.get() + if (now - last < 5_000L && source.startsWith("periodic:")) { + return + } + lastRelayLanRetryMs.set(now) + Log.i(TAG, "Attempting LAN reconnect while relay is active (source=$source)") CoroutineScope(Dispatchers.IO).launch { try { @@ -846,7 +938,7 @@ object WebSocketUtil { if (targetConnection != null) { // Discover fresh IPs via UDP burst first, then attempt connect UDPDiscoveryManager.burstBroadcast(context) - delay(1500) // Allow time for discovery responses + delay(2000) // Allow time for discovery responses // Check discovered devices for the target val discovered = UDPDiscoveryManager.discoveredDevices.value From 8999d8f9f35fc7213f4518a776ab538673436464 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 19 Mar 2026 01:08:25 +0100 Subject: [PATCH 16/29] feat: bootstrap LAN-first probing on app restart with active relay - Implemented immediate LAN-first probing in `AirSyncViewModel` when a relay connection is active but the local WebSocket is disconnected. - Added relay-status checks in `requestAutoReconnect` to trigger LAN discovery during cold starts or process restarts. - Configured bootstrap probes to run immediately with a backoff reset to prioritize local path transitions over existing relay connections. --- .../presentation/viewmodel/AirSyncViewModel.kt | 11 +++++++++++ .../java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 11 +++++++++++ 2 files changed, 22 insertions(+) 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 1b6ab87a..61b434ab 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 @@ -380,6 +380,17 @@ class AirSyncViewModel( ) 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() // If we have PC name from QR code and not already connected, store it temporarily for the dialog 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 c769eeb5..ec30b960 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -898,6 +898,17 @@ 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. + startLanFirstRelayProbe( + context = context, + immediate = true, + source = "requestAutoReconnect_relay_bootstrap", + resetBackoff = true + ) + } tryStartAutoReconnect(context) } From 6c32f1dc2892f8ae087ac0d4fbf1779da5b1920d Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 19 Mar 2026 17:07:59 +0100 Subject: [PATCH 17/29] feat: Enhance transport offer handling and candidate extraction for LAN reconnections - Added multiple transport offer triggers in `AirSyncService` for various network states to improve LAN reconnection attempts. - Implemented candidate extraction logic in `WebSocketMessageHandler` to validate and process incoming transport offers. - Introduced new methods for handling transport offers and answers, ensuring robust negotiation during LAN-first probing. - Enhanced `WebSocketUtil` with additional checks and state management for transport generation and LAN probing cooldowns. --- .../airsync/service/AirSyncService.kt | 12 + .../airsync/utils/AirBridgeClient.kt | 6 + .../airsync/utils/WebSocketMessageHandler.kt | 239 ++++++++++++ .../sameerasw/airsync/utils/WebSocketUtil.kt | 344 +++++++++++++++++- 4 files changed, 598 insertions(+), 3 deletions(-) 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 29fad8fa..04a5e2ad 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -108,6 +108,10 @@ class AirSyncService : Service() { 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, @@ -169,6 +173,10 @@ class AirSyncService : Service() { 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, @@ -182,6 +190,10 @@ class AirSyncService : Service() { 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, diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index 18a7e5f9..ccd6b483 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -461,6 +461,12 @@ object AirBridgeClient { WebSocketUtil.notifyPeerTransportChanged(transport, force = true) appContext?.let { ctx -> if (!WebSocketUtil.isConnected()) { + val generation = WebSocketUtil.nextTransportGeneration() + WebSocketUtil.sendTransportOffer( + context = ctx, + reason = "relay_started", + generation = generation + ) WebSocketUtil.startLanFirstRelayProbe( context = ctx, immediate = true, 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 b7f2cc68..1ad6211e 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 @@ -85,6 +100,11 @@ object WebSocketMessageHandler { "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) @@ -464,6 +484,225 @@ object WebSocketMessageHandler { } } + 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" + Log.d(TAG, "transport_sync: phase=offer_rx source=$source generation=$generation") + + if (!WebSocketUtil.isLanNegotiationAllowed(context)) { + Log.d(TAG, "transport_sync: phase=offer_drop generation=$generation reason=no_lan_network") + return + } + + if (!isTransportMessageFresh(data)) { + Log.w(TAG, "transport_sync: phase=offer_drop generation=$generation reason=stale_ts") + 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, "transport_sync: phase=offer_drop generation=$generation reason=invalid_candidates details=${candidateResult.invalidReason()}") + 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 + val source = data?.optString("source", "peer") ?: "peer" + Log.d(TAG, "transport_sync: phase=answer_rx source=$source generation=$generation") + + if (!WebSocketUtil.isLanNegotiationAllowed(context)) { + Log.d(TAG, "transport_sync: phase=answer_drop generation=$generation reason=no_lan_network") + return + } + + if (!isTransportMessageFresh(data)) { + Log.w(TAG, "transport_sync: phase=answer_drop generation=$generation reason=stale_ts") + 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, "transport_sync: phase=answer_drop generation=$generation reason=invalid_candidates details=${candidateResult.invalidReason()}") + 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" + val source = data?.optString("source", "peer") ?: "peer" + Log.d(TAG, "transport_sync: phase=nominate_rx source=$source generation=$generation path=$path") + if (!WebSocketUtil.isTransportGenerationActive(generation)) { + Log.w(TAG, "transport_sync: phase=nominate_drop source=$source generation=$generation reason=inactive_generation") + return + } + if (path == "lan") { + if (!WebSocketUtil.isConnected() || !WebSocketUtil.isTransportGenerationValidated(generation)) { + Log.w(TAG, "transport_sync: phase=nominate_drop source=$source generation=$generation reason=lan_not_validated") + 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 ec30b960..d8792f62 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -15,10 +15,16 @@ 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. @@ -53,12 +59,27 @@ object WebSocketUtil { 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 @@ -117,6 +138,12 @@ object WebSocketUtil { ) { appContext = context.applicationContext + if (!isLanNegotiationAllowed(context)) { + stopLanFirstRelayProbe("no_lan_network") + Log.d(TAG, "Skipping LAN-first probe: active network is not LAN/Wi-Fi") + return + } + if (!AirBridgeClient.isRelayConnectedOrConnecting()) { stopLanFirstRelayProbe("relay_not_active") return @@ -126,6 +153,7 @@ object WebSocketUtil { if (relayLanProbeJob?.isActive == true) { if (resetBackoff) { relayLanProbeStartedAtMs.set(now) + resetLanProbeFailureState("reset_by:$source") Log.d(TAG, "LAN-first probe backoff reset (source=$source)") } Log.d(TAG, "LAN-first probe already running (source=$source resetBackoff=$resetBackoff)") @@ -138,6 +166,9 @@ object WebSocketUtil { } relayLanProbeStartedAtMs.set(now) + if (resetBackoff) { + resetLanProbeFailureState("reset_by:$source") + } relayLanProbeJob = CoroutineScope(Dispatchers.IO).launch { Log.i(TAG, "Starting LAN-first probe loop (source=$source resetBackoff=$resetBackoff)") if (immediate) { @@ -147,6 +178,14 @@ object WebSocketUtil { 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 + Log.d(TAG, "LAN-first probe in cooldown, remaining=${remaining}ms") + delay(minOf(remaining, intervalMs)) + continue + } delay(intervalMs) if (isConnected.get()) { Log.d(TAG, "Stopping LAN-first probe: LAN is connected") @@ -683,6 +722,7 @@ object WebSocketUtil { SyncManager.stopPeriodicSync() lastAdvertisedTransport.set(null) stopLanFirstRelayProbe("manual_disconnect") + resetLanProbeFailureState("manual_disconnect") webSocket?.close(1000, "Manual disconnection") webSocket = null @@ -745,6 +785,7 @@ object WebSocketUtil { handshakeCompleted.set(false) handshakeTimeoutJob?.cancel() stopLanFirstRelayProbe("cleanup") + resetLanProbeFailureState("cleanup") appContext = null } @@ -902,6 +943,7 @@ object WebSocketUtil { // 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, @@ -923,8 +965,16 @@ object WebSocketUtil { } fun requestLanReconnectFromRelay(context: Context, source: String) { - if (isConnected.get() || isConnecting.get()) return + if (!isLanNegotiationAllowed(context)) { + Log.d(TAG, "Skipping LAN reconnect from relay: no LAN network (source=$source)") + 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 @@ -947,8 +997,15 @@ object WebSocketUtil { val targetConnection = all.firstOrNull { it.deviceName == last.name } if (targetConnection != null) { - // Discover fresh IPs via UDP burst first, then attempt connect - UDPDiscoveryManager.burstBroadcast(context) + // 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 @@ -970,6 +1027,7 @@ object WebSocketUtil { onConnectionStatus = { connected -> if (connected) { Log.i(TAG, "LAN reconnect succeeded — relay stays warm as backup") + resetLanProbeFailureState("lan_reconnect_success") CoroutineScope(Dispatchers.IO).launch { try { ds.updateNetworkDeviceLastConnected( @@ -980,19 +1038,299 @@ object WebSocketUtil { } } else { Log.d(TAG, "LAN reconnect from relay failed — staying on relay") + markLanProbeFailure("lan_reconnect_failed:$source") } } ) } else { Log.d(TAG, "No target connection found for LAN reconnect from relay") + 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: ${e.message}") } } } + private fun resetLanProbeFailureState(reason: String) { + consecutiveLanProbeFailures.set(0) + lanProbeCooldownUntilMs.set(0L) + Log.d(TAG, "LAN-first probe failure state reset ($reason)") + } + + private fun markLanProbeFailure(reason: String) { + val fails = consecutiveLanProbeFailures.incrementAndGet() + Log.d(TAG, "LAN-first probe failure #$fails ($reason)") + 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 until=$until 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 + Log.d(TAG, "transport_sync: phase=round_begin generation=$generation reason=$reason") + } + + 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, "transport_sync: phase=drop_stale_generation incoming=$generation active=$current reason=$reason") + 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) + Log.d(TAG, "transport_sync: phase=round_validated generation=$generation reason=$reason") + } + + 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)) { + Log.d(TAG, "transport_sync: phase=offer_drop source=android reason=no_lan_network trigger=$reason") + 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() + + Log.d(TAG, "transport_sync: phase=offer source=android generation=$generation reason=$reason") + return sendMessage(payload) + } + + fun sendTransportAnswer(generation: Long, reason: String, context: Context? = appContext): Boolean { + if (context == null || !isLanNegotiationAllowed(context)) { + Log.d(TAG, "transport_sync: phase=answer_drop source=android generation=$generation reason=no_lan_network") + return false + } + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "transport_sync: phase=answer_drop generation=$generation reason=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() + Log.d(TAG, "transport_sync: phase=answer source=android generation=$generation reason=$reason") + return sendMessage(payload) + } + + fun sendTransportCheck(generation: Long, reason: String): Boolean { + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "transport_sync: phase=check_drop generation=$generation reason=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_sync: phase=check_timeout generation=$generation token=$token") + 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() + Log.d(TAG, "transport_sync: phase=check source=android generation=$generation token=$token") + return sendMessage(payload) + } + + fun sendTransportCheckAck(generation: Long, token: String): Boolean { + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "transport_sync: phase=check_ack_drop generation=$generation reason=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() + Log.d(TAG, "transport_sync: phase=check_ack source=android generation=$generation token=$token") + return sendMessage(payload) + } + + fun onTransportCheckAck(generation: Long, token: String) { + if (!isTransportGenerationActive(generation)) { + Log.d(TAG, "transport_sync: phase=check_ack_drop generation=$generation reason=inactive_generation") + return + } + val pendingGeneration = pendingTransportCheckGeneration.get() + val pendingToken = pendingTransportCheckToken.get() + if (pendingGeneration != generation || pendingToken != token) { + Log.d(TAG, "transport_sync: phase=check_ack_stale generation=$generation token=$token") + return + } + transportCheckTimeoutJob?.cancel() + transportCheckTimeoutJob = null + pendingTransportCheckToken.set(null) + pendingTransportCheckGeneration.set(0L) + reportLanNegotiationSuccess("check_ack") + if (!isConnected()) { + Log.w(TAG, "transport_sync: phase=check_ack_drop generation=$generation reason=lan_not_connected") + return + } + markTransportGenerationValidated(generation, "check_ack") + notifyPeerTransportChanged("wifi", force = true) + sendTransportNominate("lan", generation, "check_ack") + Log.i(TAG, "transport_sync: phase=nominate_lan source=android generation=$generation reason=check_ack") + } + + fun sendTransportNominate(path: String, generation: Long, reason: String): Boolean { + if (!isTransportGenerationActive(generation)) { + Log.w(TAG, "transport_sync: phase=nominate_drop generation=$generation reason=inactive_generation") + return false + } + if (path == "lan" && !isTransportGenerationValidated(generation)) { + Log.w(TAG, "transport_sync: phase=nominate_drop generation=$generation reason=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() + Log.d(TAG, "transport_sync: phase=nominate source=android generation=$generation path=$path reason=$reason") + 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 + } + } + } From 465320647de1c4adbbe4d32313d1f8636ed5d123 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Sat, 21 Mar 2026 18:00:31 +0100 Subject: [PATCH 18/29] feat: Simplify relay connection label and peer health indicator - Updated relay connection text from "via AirBridge relay" to "AirBridge" for a more concise UI. - Replaced the textual "Peer online/offline" status badge with a compact colored status dot. --- .../components/cards/ConnectionStatusCard.kt | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) 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 cb754e03..dd246330 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 @@ -167,27 +167,19 @@ fun ConnectionStatusCard( tint = MaterialTheme.colorScheme.onTertiaryContainer ) Text( - text = "via AirBridge relay", + text = "AirBridge", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer ) } } - // Peer health badge when connected via relay - Surface( - shape = RoundedCornerShape(12.dp), - color = if (peerReallyActive) Color(0xFF4CAF50).copy(alpha = 0.16f) else Color( - 0xFFFF9800 - ).copy(alpha = 0.16f) - ) { - Text( - text = if (peerReallyActive) "Peer online" else "Peer offline", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = if (peerReallyActive) Color(0xFF4CAF50) else Color(0xFFFF9800) - ) - } + // 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() } From 8321deb47dbc7575959a7d81e7e6fd7a28737bcd Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 25 Mar 2026 19:14:04 +0100 Subject: [PATCH 19/29] feat: Implement HMAC-SHA256 challenge-response authentication for relay - Replaced static secret hashing with an HMAC-SHA256 challenge-response flow in `AirBridgeClient`. - Added `CHALLENGE_RECEIVED` state to track the authentication phase after receiving a server nonce. - Implemented `computeHmac` to generate an HMAC signature and `kInit` session bootstrap key. - Updated `onOpen` to wait for a server challenge instead of immediately sending registration. - Updated `AirBridgeCard` UI to display "Authenticating..." during the challenge-response phase. - Removed unused `isPeerReallyActive` helper and cleaned up stale variable references. --- .../ui/components/cards/AirBridgeCard.kt | 2 + .../airsync/utils/AirBridgeClient.kt | 167 +++++++++++------- 2 files changed, 105 insertions(+), 64 deletions(-) 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 index 8844f4b8..9bb7b145 100644 --- 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 @@ -134,6 +134,7 @@ fun AirBridgeCard(context: Context) { 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) @@ -146,6 +147,7 @@ fun AirBridgeCard(context: Context) { 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" diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index ccd6b483..0f8716cb 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -21,11 +21,17 @@ 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" @@ -34,6 +40,7 @@ object AirBridgeClient { enum class State { DISCONNECTED, CONNECTING, + CHALLENGE_RECEIVED, // Received nonce, computing HMAC REGISTERING, WAITING_FOR_PEER, RELAY_ACTIVE, @@ -124,7 +131,7 @@ object AirBridgeClient { } val relayUrl = ds.getAirBridgeRelayUrl().first() val pairingId = ds.getAirBridgePairingId().first() - val secret = ds.getAirBridgeSecret().first() + val secretRaw = ds.getAirBridgeSecret().first() // Validate config values before connecting to prevent futile attempts and log spam. if (relayUrl.isBlank()) { @@ -134,7 +141,7 @@ object AirBridgeClient { } // Pairing ID and secret are required for registration, so treat missing values as failed state to prompt user action. - if (pairingId.isBlank() || secret.isBlank()) { + if (pairingId.isBlank() || secretRaw.isBlank()) { Log.w(TAG, "Pairing ID or secret is empty, skipping connection") setState(State.FAILED, "Missing pairing credentials") return@launch @@ -154,7 +161,8 @@ object AirBridgeClient { Log.d(TAG, "Symmetric key resolved for relay transport") } - connectInternal(relayUrl, pairingId, hashSecret(secret)) + // Pass raw secret — HMAC computation happens after receiving challenge + connectInternal(relayUrl, pairingId, secretRaw) } catch (e: Exception) { Log.e(TAG, "Failed to read AirBridge config: ${e.message}") setState(State.FAILED, "Failed reading persisted config") @@ -210,6 +218,7 @@ object AirBridgeClient { State.RELAY_ACTIVE, State.WAITING_FOR_PEER, State.REGISTERING, + State.CHALLENGE_RECEIVED, State.CONNECTING -> { // Already connected/connecting return@launch @@ -305,12 +314,6 @@ object AirBridgeClient { */ fun isRelayActive(): Boolean = _connectionState.value == State.RELAY_ACTIVE - /** - * Returns true if the relay reports that both Mac and Android are currently attached - * to the same pairing session (based on the latest status_reply snapshot). - */ - fun isPeerReallyActive(): Boolean = isRelayActive() && lastStatusBothConnected - /** * Returns true if relay transport is already usable or being established. * Useful to suppress noisy LAN reconnect loops while relay failover is in progress. @@ -320,6 +323,7 @@ object AirBridgeClient { State.RELAY_ACTIVE, State.WAITING_FOR_PEER, State.REGISTERING, + State.CHALLENGE_RECEIVED, State.CONNECTING -> true else -> false } @@ -332,10 +336,38 @@ object AirBridgeClient { 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, secret: String) { + private fun connectInternal(relayUrl: String, pairingId: String, secretRaw: String) { // Allocate a fresh generation so callbacks from older sockets are ignored. val generation = activeConnectionGeneration.incrementAndGet() @@ -365,63 +397,42 @@ object AirBridgeClient { val listener = object : WebSocketListener() { fun isStale(): Boolean = generation != activeConnectionGeneration.get() - // On open, send the registration message with the pairing ID and secret hash. - override fun onOpen(ws: WebSocket, response: Response) { + // On open, wait for the server's challenge (do NOT send register immediately) + override fun onOpen(webSocket: WebSocket, response: Response) { if (isStale()) { try { - ws.close(1000, "Stale relay socket") + webSocket.close(1000, "Stale relay socket") } catch (_: Exception) {} return } - Log.d(TAG, "WebSocket opened to relay") - webSocket = ws - setState(State.REGISTERING, "Socket open, sending register") + Log.d(TAG, "WebSocket opened to relay, waiting for challenge...") + this@AirBridgeClient.webSocket = webSocket + setState(State.CONNECTING, "Socket open, waiting for challenge") reconnectAttempt = 0 - - // Send registration - val regMsg = JSONObject().apply { - put("action", "register") - put("role", "android") - put("pairingId", pairingId) - put("secret", secret) - put("localIp", DeviceInfoUtil.getWifiIpAddress(appContext!!) ?: "unknown") - put("port", 0) // Android doesn't run a server it's the client - } - - if (ws.send(regMsg.toString())) { - Log.d(TAG, "Registration sent for pairingId: $pairingId") - 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") - } } - override fun onMessage(ws: WebSocket, text: String) { + override fun onMessage(webSocket: WebSocket, text: String) { if (isStale()) return - handleTextMessage(text) + handleTextMessage(text, webSocket, pairingId, secretRaw) } - override fun onClosing(ws: WebSocket, code: Int, reason: String) { + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { if (isStale()) return Log.d(TAG, "Relay closing: $code $reason") - ws.close(1000, null) + 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 (webSocket == ws) { - webSocket = null + if (this@AirBridgeClient.webSocket == webSocket) { + this@AirBridgeClient.webSocket = null } - scheduleReconnect(relayUrl, pairingId, secret, generation) + scheduleReconnect(relayUrl, pairingId, secretRaw, generation) } - override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + 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: $msg") @@ -431,23 +442,61 @@ object AirBridgeClient { statusQueryJob = null lastStatusBothConnected = false _peerReallyActive.value = false - if (webSocket == ws) { - webSocket = null + if (this@AirBridgeClient.webSocket == webSocket) { + this@AirBridgeClient.webSocket = null } - scheduleReconnect(relayUrl, pairingId, secret, generation) + scheduleReconnect(relayUrl, pairingId, secretRaw, generation) } } client!!.newWebSocket(request, listener) } - private fun handleTextMessage(text: String) { + 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 + } + + Log.d(TAG, "Challenge received, computing HMAC...") + 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())) { + Log.d(TAG, "Registration sent (HMAC auth) for pairingId: $pairingId") + 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" -> { Log.i(TAG, "Relay tunnel established!") setState(State.RELAY_ACTIVE, "Server confirmed relay tunnel") @@ -461,11 +510,11 @@ object AirBridgeClient { WebSocketUtil.notifyPeerTransportChanged(transport, force = true) appContext?.let { ctx -> if (!WebSocketUtil.isConnected()) { - val generation = WebSocketUtil.nextTransportGeneration() + val transportGeneration = WebSocketUtil.nextTransportGeneration() WebSocketUtil.sendTransportOffer( context = ctx, reason = "relay_started", - generation = generation + generation = transportGeneration ) WebSocketUtil.startLanFirstRelayProbe( context = ctx, @@ -537,12 +586,12 @@ object AirBridgeClient { } appContext?.let { ctx -> - onMessageReceived?.invoke(ctx, processedMessage!!) + 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, secret: String, sourceGeneration: Long) { + private fun scheduleReconnect(relayUrl: String, pairingId: String, secretRaw: String, sourceGeneration: Long) { if (isManuallyDisconnected.get()) return if (sourceGeneration != activeConnectionGeneration.get()) return @@ -559,7 +608,7 @@ object AirBridgeClient { reconnectJob = CoroutineScope(Dispatchers.IO).launch { delay(delayMs) if (!isManuallyDisconnected.get() && sourceGeneration == activeConnectionGeneration.get()) { - connectInternal(relayUrl, pairingId, secret) + connectInternal(relayUrl, pairingId, secretRaw) } } } @@ -587,16 +636,6 @@ object AirBridgeClient { } } - /** - * SHA-256 hashes the raw secret so the plaintext never leaves the device. - * The relay server only ever sees (and stores) this hash. - */ - private fun hashSecret(raw: String): String { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(raw.toByteArray(Charsets.UTF_8)) - return hash.joinToString("") { "%02x".format(it) } - } - /** * Normalizes the relay URL: adds ws:// or wss:// prefix and /ws suffix. * Uses ws:// for localhost/private IPs, wss:// for remote domains. From 4c98c1b1b5bd2a31d9b0eb188f5afee13001fbc2 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 16:21:34 +0100 Subject: [PATCH 20/29] chore: simplify query parameter parsing logic - Updated the query string splitting logic in `MainActivity` to use '?' as the sole delimiter. - Removed the regex-based split that provided backward compatibility for '&' separators. --- app/src/main/java/com/sameerasw/airsync/MainActivity.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index dba873e3..32bd0b94 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -613,9 +613,7 @@ class MainActivity : ComponentActivity() { val queryPart = urlString.substringAfter('?', "") if (queryPart.isEmpty()) return emptyMap() - // Backward-compatible parsing: - // old QR used "?" as separator, newer QR may use "&". - return queryPart.split("[?&]".toRegex()) + return queryPart.split('?') .mapNotNull { raw -> if (raw.isBlank()) return@mapNotNull null val parts = raw.split('=', limit = 2) From 94e4be3bdb30b145faca7f733d8512b7dbd84548 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 16:22:33 +0100 Subject: [PATCH 21/29] feat: Refine AirSyncMainScreen UI and URI parsing - Simplify URI query parameter parsing logic by using a single delimiter. - Re-enable the FAB expansion hint on connection status changes with a 5-second auto-collapse. - Remove `AnimatedVisibility` from the `AirSyncFloatingToolbar` to ensure it remains visible regardless of scroll state. - Clean up unused `SuppressLint` and optimize scroll tracking state. --- .../ui/screens/AirSyncMainScreen.kt | 142 ++++++++---------- 1 file changed, 64 insertions(+), 78 deletions(-) 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 9d339ef5..e86a1383 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 @@ -1,6 +1,5 @@ package com.sameerasw.airsync.presentation.ui.screens -import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.util.Log @@ -131,13 +130,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject +import java.net.URLDecoder @OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class ) -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable fun AirSyncMainScreen( modifier: Modifier = Modifier, @@ -446,7 +445,7 @@ fun AirSyncMainScreen( } } - // Parse query parameters (legacy '?' and modern '&' separators) + // Parse query parameters var pcName: String? = null var isPlus = false var symmetricKey: String? = null @@ -456,7 +455,7 @@ fun AirSyncMainScreen( val queryPart = uri.toString().substringAfter('?', "") if (queryPart.isNotEmpty()) { - val paramMap = queryPart.split("[?&]".toRegex()) + val paramMap = queryPart.split('?') .mapNotNull { raw -> if (raw.isBlank()) return@mapNotNull null val parts = raw.split('=', limit = 2) @@ -601,22 +600,21 @@ fun AirSyncMainScreen( // Hide FAB on scroll down, show on scroll up for the active tab LaunchedEffect(pagerState.currentPage) { val state = if (pagerState.currentPage == 0) connectScrollState else settingsScrollState - var last = state.value + val last = state.value snapshotFlow { state.value }.collect { value -> val delta = value - last if (delta > 2) fabVisible = false else if (delta < -2) fabVisible = true - last = value } } // Expand FAB on first launch and whenever variant changes (connect <-> disconnect), then collapse after 5s - // LaunchedEffect(uiState.isConnected) { - // fabExpanded = true - // // Give users a hint for a short period, then collapse to icon-only - // delay(5000) - // fabExpanded = false - // } + LaunchedEffect(uiState.isConnected) { + fabExpanded = true + // Give users a hint for a short period, then collapse to icon-only + delay(5000) + fabExpanded = false + } // Start/stop clipboard sync based on connection status and settings LaunchedEffect(uiState.isConnected, uiState.isClipboardSyncEnabled) { @@ -1235,41 +1233,35 @@ fun AirSyncMainScreen( ) } - AnimatedVisibility( - visible = fabVisible, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally(), - ) { - AirSyncFloatingToolbar( - modifier = Modifier.zIndex(1f), - currentPage = pagerState.currentPage, - tabs = tabs, - onTabSelected = { index -> - scope.launch { - val distance = kotlin.math.abs(index - pagerState.currentPage) - if (distance == 1) { - pagerState.animateScrollToPage(index) - } else { - pagerState.scrollToPage(index) - } + AirSyncFloatingToolbar( + modifier = Modifier.zIndex(1f), + currentPage = pagerState.currentPage, + tabs = tabs, + onTabSelected = { index -> + scope.launch { + val distance = kotlin.math.abs(index - pagerState.currentPage) + if (distance == 1) { + pagerState.animateScrollToPage(index) + } else { + pagerState.scrollToPage(index) } - }, - floatingActionButton = { - MainFAB( - currentTab = tabs.getOrNull(pagerState.currentPage), - isConnected = uiState.isConnected, - onAction = { action -> - when (action) { - "keyboard" -> showKeyboard = !showKeyboard - "clear_history" -> viewModel.clearClipboardHistory() - "disconnect" -> disconnect() - "scan" -> launchScanner(context) - } - } - ) } - ) - } + }, + floatingActionButton = { + MainFAB( + currentTab = tabs.getOrNull(pagerState.currentPage), + isConnected = uiState.isConnected, + onAction = { action -> + when (action) { + "keyboard" -> showKeyboard = !showKeyboard + "clear_history" -> viewModel.clearClipboardHistory() + "disconnect" -> disconnect() + "scan" -> launchScanner(context) + } + } + ) + } + ) } } else { // Portrait: Stacked @@ -1300,41 +1292,35 @@ fun AirSyncMainScreen( ) } - AnimatedVisibility( - visible = fabVisible, - enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), - exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom), - ) { - AirSyncFloatingToolbar( - modifier = Modifier.zIndex(1f), - currentPage = pagerState.currentPage, - tabs = tabs, - onTabSelected = { index -> - scope.launch { - val distance = kotlin.math.abs(index - pagerState.currentPage) - if (distance == 1) { - pagerState.animateScrollToPage(index) - } else { - pagerState.scrollToPage(index) - } + AirSyncFloatingToolbar( + modifier = Modifier.zIndex(1f), + currentPage = pagerState.currentPage, + tabs = tabs, + onTabSelected = { index -> + scope.launch { + val distance = kotlin.math.abs(index - pagerState.currentPage) + if (distance == 1) { + pagerState.animateScrollToPage(index) + } else { + pagerState.scrollToPage(index) } - }, - floatingActionButton = { - MainFAB( - currentTab = tabs.getOrNull(pagerState.currentPage), - isConnected = uiState.isConnected, - onAction = { action -> - when (action) { - "keyboard" -> showKeyboard = !showKeyboard - "clear_history" -> viewModel.clearClipboardHistory() - "disconnect" -> disconnect() - "scan" -> launchScanner(context) - } - } - ) } - ) - } + }, + floatingActionButton = { + MainFAB( + currentTab = tabs.getOrNull(pagerState.currentPage), + isConnected = uiState.isConnected, + onAction = { action -> + when (action) { + "keyboard" -> showKeyboard = !showKeyboard + "clear_history" -> viewModel.clearClipboardHistory() + "disconnect" -> disconnect() + "scan" -> launchScanner(context) + } + } + ) + } + ) } } } From 2cfb4fee1c973e23d7bc0a476a2424e03aecd99c Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 16:24:14 +0100 Subject: [PATCH 22/29] clean: Refactor and optimize logging for AirBridge and transport synchronization - Removed redundant and verbose debug/info logs across `AirBridgeClient`, `WebSocketUtil`, and `WebSocketMessageHandler` to streamline production output. - Simplified error messages by removing exception detail string concatenation in several `Log.e` calls. - Renamed unused parameters (e.g., `reason` to `_reason`) to satisfy linting or clarify intent. - Improved log safety by removing specific PII/metadata like IP addresses and port numbers from certain debug statements. - Retained critical security warnings and high-level state transition failures. - Streamlined transport synchronization logic by dropping stale or inactive generation updates with more concise warnings. --- .../airsync/utils/AirBridgeClient.kt | 48 ++---------- .../airsync/utils/WebSocketMessageHandler.kt | 29 ++------ .../sameerasw/airsync/utils/WebSocketUtil.kt | 74 +++---------------- 3 files changed, 27 insertions(+), 124 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index 0f8716cb..b1f89a7c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -87,13 +87,7 @@ object AirBridgeClient { private var onMessageReceived: ((Context, String) -> Unit)? = null // Updates the connection state and logs the transition reason. - private fun setState(newState: State, reason: String) { - val oldState = _connectionState.value - if (oldState != newState) { - Log.i(TAG, "State: $oldState -> $newState | $reason") - } else { - Log.d(TAG, "State unchanged: $newState | $reason") - } + private fun setState(newState: State, _reason: String) { _connectionState.value = newState if (newState != State.RELAY_ACTIVE) { _peerReallyActive.value = false @@ -157,14 +151,12 @@ object AirBridgeClient { Log.e(TAG, "SECURITY: No symmetric key resolved — refusing relay connection to prevent plaintext transport") setState(State.FAILED, "No encryption key available") return@launch - } else { - Log.d(TAG, "Symmetric key resolved for relay transport") } // Pass raw secret — HMAC computation happens after receiving challenge connectInternal(relayUrl, pairingId, secretRaw) } catch (e: Exception) { - Log.e(TAG, "Failed to read AirBridge config: ${e.message}") + Log.e(TAG, "Failed to read AirBridge config") setState(State.FAILED, "Failed reading persisted config") } finally { connectInProgress.set(false) @@ -177,9 +169,6 @@ object AirBridgeClient { */ fun updateSymmetricKey(base64Key: String?) { symmetricKey = base64Key?.let { CryptoUtil.decodeKey(it) } - if (symmetricKey != null) { - Log.d(TAG, "Relay symmetric key updated from active session") - } } // Resolves the symmetric key for relay encryption/decryption. @@ -210,7 +199,6 @@ object AirBridgeClient { val ds = DataStoreManager.getInstance(context) val enabled = ds.getAirBridgeEnabled().first() if (!enabled) { - Log.d(TAG, "ensureConnected skipped: AirBridge disabled") return@launch } // If already connected or in the process of connecting, do nothing. Otherwise, attempt to connect. @@ -228,13 +216,12 @@ object AirBridgeClient { reconnectJob?.cancel() reconnectJob = null reconnectAttempt = 0 - Log.i(TAG, "ensureConnected: forcing immediate reconnect") } connect(context) } } } catch (e: Exception) { - Log.e(TAG, "ensureConnected failed: ${e.message}") + Log.e(TAG, "ensureConnected failed") } } } @@ -264,7 +251,6 @@ object AirBridgeClient { client = null setState(State.DISCONNECTED, "Manual disconnect") - Log.d(TAG, "Disconnected manually") } /** @@ -294,17 +280,10 @@ object AirBridgeClient { return false } - val type = try { - JSONObject(message).optString("type", "unknown") - } catch (_: Exception) { - "non_json" - } - return try { - Log.d(TAG, "Relay TX type=$type bytes=${messageToSend.length}") ws.send(messageToSend) } catch (e: Exception) { - Log.e(TAG, "Failed to send relay message: ${e.message}") + Log.e(TAG, "Failed to send relay message") false } } @@ -376,8 +355,6 @@ object AirBridgeClient { // 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) - Log.d(TAG, "Connecting to relay: $normalizedUrl") - // Lazily initialize OkHttpClient with timeouts suitable for a long-lived relay connection. if (client == null) { client = OkHttpClient.Builder() @@ -405,7 +382,6 @@ object AirBridgeClient { } catch (_: Exception) {} return } - Log.d(TAG, "WebSocket opened to relay, waiting for challenge...") this@AirBridgeClient.webSocket = webSocket setState(State.CONNECTING, "Socket open, waiting for challenge") reconnectAttempt = 0 @@ -418,7 +394,6 @@ object AirBridgeClient { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { if (isStale()) return - Log.d(TAG, "Relay closing: $code $reason") webSocket.close(1000, null) setState(State.DISCONNECTED, "Socket closing code=$code") WebSocketUtil.stopLanFirstRelayProbe("relay_onClosing") @@ -435,7 +410,7 @@ object AirBridgeClient { 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: $msg") + Log.e(TAG, "Relay connection failed") setState(State.FAILED, "Socket failure: $msg") WebSocketUtil.stopLanFirstRelayProbe("relay_onFailure") statusQueryJob?.cancel() @@ -468,7 +443,6 @@ object AirBridgeClient { return } - Log.d(TAG, "Challenge received, computing HMAC...") setState(State.CHALLENGE_RECEIVED, "Computing HMAC signature") val (sig, kInit) = computeHmac(secretRaw, nonce, pairingId) @@ -486,7 +460,6 @@ object AirBridgeClient { setState(State.REGISTERING, "Sending register with HMAC") if (ws.send(regMsg.toString())) { - Log.d(TAG, "Registration sent (HMAC auth) for pairingId: $pairingId") setState(State.WAITING_FOR_PEER, "Registration accepted, waiting peer") // Reset status cache on fresh registration lastStatusBothConnected = false @@ -498,7 +471,6 @@ object AirBridgeClient { return } "relay_started" -> { - Log.i(TAG, "Relay tunnel established!") setState(State.RELAY_ACTIVE, "Server confirmed relay tunnel") reconnectJob?.cancel() reconnectJob = null @@ -532,7 +504,7 @@ object AirBridgeClient { delay(1000) // Stabilize connection before sending data SyncManager.performInitialSync(ctx) } catch (e: Exception) { - Log.e(TAG, "Failed to perform initial sync via relay: ${e.message}") + Log.e(TAG, "Failed to perform initial sync via relay") } } } @@ -545,17 +517,15 @@ object AirBridgeClient { val androidActive = json.optBoolean("androidActive", false) lastStatusBothConnected = both && macActive && androidActive _peerReallyActive.value = isRelayActive() && lastStatusBothConnected - Log.d(TAG, "Relay status_reply: macActive=$macActive androidActive=$androidActive bothConnected=$both") 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 - Log.d(TAG, "Received mac_info from relay") } "error" -> { val msg = json.optString("message", "Unknown error") - Log.e(TAG, "Relay server error: $msg") + Log.e(TAG, "Relay server error") setState(State.FAILED, "Server error action: $msg") return } @@ -601,7 +571,6 @@ object AirBridgeClient { ) reconnectAttempt++ - Log.d(TAG, "Reconnecting in ${delayMs}ms (attempt $reconnectAttempt)") setState(State.CONNECTING, "Backoff reconnect scheduled in ${delayMs}ms") reconnectJob?.cancel() @@ -632,7 +601,6 @@ object AirBridgeClient { try { ws.send(msg) } catch (e: Exception) { - Log.d(TAG, "query_status send failed: ${e.message}") } } @@ -656,7 +624,7 @@ object AirBridgeClient { // 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: $host") + Log.w(TAG, "SECURITY: Upgrading ws:// to wss:// for public host") url = "wss://" + url.removePrefix("ws://") } 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 1ad6211e..292f23fd 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -431,9 +431,6 @@ object WebSocketMessageHandler { val port = data?.optInt("port", -1) ?: -1 val adapter = data?.optString("adapter", "auto") ?: "auto" - Log.d(TAG, "transport_sync: direction=mac->android type=macWake ips=$ips port=$port adapter=$adapter") - Log.d(TAG, "Received macWake via relay – attempting immediate LAN and enabling LAN-first probe") - CoroutineScope(Dispatchers.IO).launch { try { val ds = DataStoreManager.getInstance(context) @@ -442,7 +439,6 @@ object WebSocketMessageHandler { if (!WebSocketUtil.isConnected() && !WebSocketUtil.isConnecting()) { if (ips.isNotBlank() && port > 0 && key != null) { - Log.d(TAG, "macWake: immediate LAN attempt to $ips:$port") WebSocketUtil.connect( context = context, ipAddress = ips, @@ -476,9 +472,7 @@ object WebSocketMessageHandler { private fun handlePeerTransport(data: JSONObject?) { try { - val transport = data?.optString("transport", "unknown") ?: "unknown" - val source = data?.optString("source", "peer") ?: "peer" - Log.d(TAG, "Peer transport update received: source=$source transport=$transport") + if (data == null) return } catch (e: Exception) { Log.e(TAG, "Error handling peerTransport: ${e.message}") } @@ -549,15 +543,12 @@ object WebSocketMessageHandler { try { val generation = data?.optLong("generation", 0L) ?: 0L val source = data?.optString("source", "peer") ?: "peer" - Log.d(TAG, "transport_sync: phase=offer_rx source=$source generation=$generation") - if (!WebSocketUtil.isLanNegotiationAllowed(context)) { - Log.d(TAG, "transport_sync: phase=offer_drop generation=$generation reason=no_lan_network") return } if (!isTransportMessageFresh(data)) { - Log.w(TAG, "transport_sync: phase=offer_drop generation=$generation reason=stale_ts") + Log.w(TAG, "Dropped stale transport offer") return } if (!WebSocketUtil.acceptIncomingTransportGeneration(generation, "offer_rx")) { @@ -578,7 +569,7 @@ object WebSocketMessageHandler { val ds = DataStoreManager.getInstance(context) val key = ds.getLastConnectedDevice().first()?.symmetricKey if (ipsCsv.isBlank() || port <= 0 || key.isNullOrBlank()) { - Log.w(TAG, "transport_sync: phase=offer_drop generation=$generation reason=invalid_candidates details=${candidateResult.invalidReason()}") + Log.w(TAG, "Dropped transport offer with invalid candidates") WebSocketUtil.reportLanNegotiationFailure("offer_missing_candidates_or_key") return@launch } @@ -607,16 +598,12 @@ object WebSocketMessageHandler { private fun handleTransportAnswer(context: Context, data: JSONObject?) { try { val generation = data?.optLong("generation", 0L) ?: 0L - val source = data?.optString("source", "peer") ?: "peer" - Log.d(TAG, "transport_sync: phase=answer_rx source=$source generation=$generation") - if (!WebSocketUtil.isLanNegotiationAllowed(context)) { - Log.d(TAG, "transport_sync: phase=answer_drop generation=$generation reason=no_lan_network") return } if (!isTransportMessageFresh(data)) { - Log.w(TAG, "transport_sync: phase=answer_drop generation=$generation reason=stale_ts") + Log.w(TAG, "Dropped stale transport answer") return } if (!WebSocketUtil.acceptIncomingTransportGeneration(generation, "answer_rx")) { @@ -635,7 +622,7 @@ object WebSocketMessageHandler { val ds = DataStoreManager.getInstance(context) val key = ds.getLastConnectedDevice().first()?.symmetricKey if (ipsCsv.isBlank() || port <= 0 || key.isNullOrBlank()) { - Log.w(TAG, "transport_sync: phase=answer_drop generation=$generation reason=invalid_candidates details=${candidateResult.invalidReason()}") + Log.w(TAG, "Dropped transport answer with invalid candidates") return@launch } @@ -685,15 +672,13 @@ object WebSocketMessageHandler { try { val generation = data?.optLong("generation", 0L) ?: 0L val path = data?.optString("path", "relay") ?: "relay" - val source = data?.optString("source", "peer") ?: "peer" - Log.d(TAG, "transport_sync: phase=nominate_rx source=$source generation=$generation path=$path") if (!WebSocketUtil.isTransportGenerationActive(generation)) { - Log.w(TAG, "transport_sync: phase=nominate_drop source=$source generation=$generation reason=inactive_generation") + Log.w(TAG, "Dropped transport nominate for inactive generation") return } if (path == "lan") { if (!WebSocketUtil.isConnected() || !WebSocketUtil.isTransportGenerationValidated(generation)) { - Log.w(TAG, "transport_sync: phase=nominate_drop source=$source generation=$generation reason=lan_not_validated") + Log.w(TAG, "Dropped LAN nominate before validation") return } WebSocketUtil.reportLanNegotiationSuccess("peer_nominate_lan") 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 d8792f62..5c8aebdf 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -96,11 +96,6 @@ object WebSocketUtil { val previous = lastAdvertisedTransport.get() if (!force && previous == transport) return true - Log.d( - TAG, - "transport_sync: direction=android->mac source=android transport_old=${previous ?: "null"} transport_new=$transport force=$force relayActive=${AirBridgeClient.isRelayConnectedOrConnecting()} lanConnected=${isConnected.get()}" - ) - val payload = JSONObject().apply { put("type", "peerTransport") put("data", JSONObject().apply { @@ -140,7 +135,6 @@ object WebSocketUtil { if (!isLanNegotiationAllowed(context)) { stopLanFirstRelayProbe("no_lan_network") - Log.d(TAG, "Skipping LAN-first probe: active network is not LAN/Wi-Fi") return } @@ -154,9 +148,7 @@ object WebSocketUtil { if (resetBackoff) { relayLanProbeStartedAtMs.set(now) resetLanProbeFailureState("reset_by:$source") - Log.d(TAG, "LAN-first probe backoff reset (source=$source)") } - Log.d(TAG, "LAN-first probe already running (source=$source resetBackoff=$resetBackoff)") if (immediate) { CoroutineScope(Dispatchers.IO).launch { requestLanReconnectFromRelay(context, source = "immediate:$source") @@ -170,7 +162,6 @@ object WebSocketUtil { resetLanProbeFailureState("reset_by:$source") } relayLanProbeJob = CoroutineScope(Dispatchers.IO).launch { - Log.i(TAG, "Starting LAN-first probe loop (source=$source resetBackoff=$resetBackoff)") if (immediate) { requestLanReconnectFromRelay(context, source = "start:$source") } @@ -182,17 +173,14 @@ object WebSocketUtil { val cooldownUntil = lanProbeCooldownUntilMs.get() if (cooldownUntil > nowLoop) { val remaining = cooldownUntil - nowLoop - Log.d(TAG, "LAN-first probe in cooldown, remaining=${remaining}ms") delay(minOf(remaining, intervalMs)) continue } delay(intervalMs) if (isConnected.get()) { - Log.d(TAG, "Stopping LAN-first probe: LAN is connected") break } if (!AirBridgeClient.isRelayConnectedOrConnecting()) { - Log.d(TAG, "Stopping LAN-first probe: relay not connected/connecting") break } requestLanReconnectFromRelay(context, source = "periodic:$source") @@ -208,11 +196,10 @@ object WebSocketUtil { } } - fun stopLanFirstRelayProbe(reason: String = "unspecified") { + fun stopLanFirstRelayProbe(_reason: String = "unspecified") { relayLanProbeJob?.cancel() relayLanProbeJob = null relayLanProbeStartedAtMs.set(0L) - Log.d(TAG, "Stopped LAN-first probe loop (reason=$reason)") } @@ -503,7 +490,6 @@ object WebSocketUtil { // Keep relay warm in background (if enabled) for instant failover. AirBridgeClient.ensureConnected(context, immediate = false) notifyPeerTransportChanged("wifi", force = true) - Log.i(TAG, "LAN handshake completed on $ip:$port, relay kept warm") try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -561,8 +547,6 @@ object WebSocketUtil { Log.w(TAG, "LAN socket closing, requested immediate relay fallback") if (!AirBridgeClient.isRelayConnectedOrConnecting()) { tryStartAutoReconnect(context) - } else { - Log.d(TAG, "Skipping LAN auto-reconnect: relay already connected/connecting") } try { AirSyncWidgetProvider.updateAllWidgets(context) @@ -623,11 +607,9 @@ object WebSocketUtil { if (AirBridgeClient.isRelayConnectedOrConnecting()) { startLanFirstRelayProbe(context, immediate = true, source = "lan_onFailure", resetBackoff = true) } - Log.w(TAG, "LAN failure, requested immediate relay fallback: ${t.message}") + Log.w(TAG, "LAN failure, requested immediate relay fallback") if (!AirBridgeClient.isRelayConnectedOrConnecting()) { tryStartAutoReconnect(context) - } else { - Log.d(TAG, "Skipping LAN auto-reconnect: relay already connected/connecting") } try { AirSyncWidgetProvider.updateAllWidgets(context) @@ -697,7 +679,6 @@ object WebSocketUtil { webSocket!!.send(messageToSend) } else if (AirBridgeClient.isRelayActive()) { // Fallback: route through AirBridge relay if local connection is down - Log.d(TAG, "TX via RELAY") AirBridgeClient.sendMessage(message) } else { Log.w(TAG, "Drop TX: no LAN/relay available") @@ -885,11 +866,6 @@ object WebSocketUtil { // Match by name within the discovery list val discoveryMatch = discoveredList.find { it.name == last.name } if (discoveryMatch != null) { - Log.d( - TAG, - "Discovery found target device: ${discoveryMatch.name} with IPs: ${discoveryMatch.ips}" - ) - val all = ds.getAllNetworkDeviceConnections().first() val targetConnection = all.firstOrNull { it.deviceName == last.name } @@ -897,10 +873,6 @@ object WebSocketUtil { val ips = discoveryMatch.ips.joinToString(",") val port = targetConnection.port.toIntOrNull() ?: 6996 - Log.d( - TAG, - "Smart Auto-reconnect attempting parallel connections to $ips:$port" - ) connect( context = context, ipAddress = ips, @@ -927,7 +899,6 @@ object WebSocketUtil { } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) { - Log.d(TAG, "Discovery auto-reconnect cancelled") } else { Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") } @@ -966,7 +937,6 @@ object WebSocketUtil { fun requestLanReconnectFromRelay(context: Context, source: String) { if (!isLanNegotiationAllowed(context)) { - Log.d(TAG, "Skipping LAN reconnect from relay: no LAN network (source=$source)") return } val now = System.currentTimeMillis() @@ -980,7 +950,6 @@ object WebSocketUtil { return } lastRelayLanRetryMs.set(now) - Log.i(TAG, "Attempting LAN reconnect while relay is active (source=$source)") CoroutineScope(Dispatchers.IO).launch { try { @@ -988,7 +957,6 @@ object WebSocketUtil { val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() if (manual || !autoEnabled) { - Log.d(TAG, "LAN reconnect from relay skipped: manual=$manual autoEnabled=$autoEnabled") return@launch } @@ -1017,7 +985,6 @@ object WebSocketUtil { ?: last.ipAddress val port = targetConnection.port.toIntOrNull() ?: 6996 - Log.i(TAG, "LAN reconnect from relay: trying $ips:$port") connect( context = context, ipAddress = ips, @@ -1026,7 +993,6 @@ object WebSocketUtil { manualAttempt = false, onConnectionStatus = { connected -> if (connected) { - Log.i(TAG, "LAN reconnect succeeded — relay stays warm as backup") resetLanProbeFailureState("lan_reconnect_success") CoroutineScope(Dispatchers.IO).launch { try { @@ -1037,20 +1003,18 @@ object WebSocketUtil { } catch (_: Exception) {} } } else { - Log.d(TAG, "LAN reconnect from relay failed — staying on relay") markLanProbeFailure("lan_reconnect_failed:$source") } } ) } else { - Log.d(TAG, "No target connection found for LAN reconnect from relay") 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: ${e.message}") + Log.e(TAG, "Error in requestLanReconnectFromRelay") } } } @@ -1058,17 +1022,15 @@ object WebSocketUtil { private fun resetLanProbeFailureState(reason: String) { consecutiveLanProbeFailures.set(0) lanProbeCooldownUntilMs.set(0L) - Log.d(TAG, "LAN-first probe failure state reset ($reason)") } private fun markLanProbeFailure(reason: String) { val fails = consecutiveLanProbeFailures.incrementAndGet() - Log.d(TAG, "LAN-first probe failure #$fails ($reason)") 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 until=$until after repeated failures") + Log.w(TAG, "LAN-first probe entering cooldown after repeated failures") } } @@ -1095,7 +1057,6 @@ object WebSocketUtil { pendingTransportCheckToken.set(null) transportCheckTimeoutJob?.cancel() transportCheckTimeoutJob = null - Log.d(TAG, "transport_sync: phase=round_begin generation=$generation reason=$reason") } fun acceptIncomingTransportGeneration(generation: Long, reason: String): Boolean { @@ -1113,7 +1074,7 @@ object WebSocketUtil { return true } - Log.w(TAG, "transport_sync: phase=drop_stale_generation incoming=$generation active=$current reason=$reason") + Log.w(TAG, "Dropping stale transport generation update") return false } @@ -1128,7 +1089,6 @@ object WebSocketUtil { fun markTransportGenerationValidated(generation: Long, reason: String) { if (!isTransportGenerationActive(generation)) return validatedTransportGeneration.set(generation) - Log.d(TAG, "transport_sync: phase=round_validated generation=$generation reason=$reason") } fun isTransportGenerationValidated(generation: Long): Boolean { @@ -1141,7 +1101,6 @@ object WebSocketUtil { fun sendTransportOffer(context: Context, reason: String, generation: Long = nextTransportGeneration()): Boolean { if (!isLanNegotiationAllowed(context)) { - Log.d(TAG, "transport_sync: phase=offer_drop source=android reason=no_lan_network trigger=$reason") return false } beginTransportRound(generation, "send_offer:$reason") @@ -1166,17 +1125,15 @@ object WebSocketUtil { }) }.toString() - Log.d(TAG, "transport_sync: phase=offer source=android generation=$generation reason=$reason") return sendMessage(payload) } fun sendTransportAnswer(generation: Long, reason: String, context: Context? = appContext): Boolean { if (context == null || !isLanNegotiationAllowed(context)) { - Log.d(TAG, "transport_sync: phase=answer_drop source=android generation=$generation reason=no_lan_network") return false } if (!isTransportGenerationActive(generation)) { - Log.w(TAG, "transport_sync: phase=answer_drop generation=$generation reason=inactive_generation") + Log.w(TAG, "Dropping transport answer for inactive generation") return false } val localIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "" @@ -1199,13 +1156,12 @@ object WebSocketUtil { put("reason", reason) }) }.toString() - Log.d(TAG, "transport_sync: phase=answer source=android generation=$generation reason=$reason") return sendMessage(payload) } fun sendTransportCheck(generation: Long, reason: String): Boolean { if (!isTransportGenerationActive(generation)) { - Log.w(TAG, "transport_sync: phase=check_drop generation=$generation reason=inactive_generation") + Log.w(TAG, "Dropping transport check for inactive generation") return false } val token = UUID.randomUUID().toString() @@ -1216,7 +1172,7 @@ object WebSocketUtil { delay(TRANSPORT_CHECK_TIMEOUT_MS) val pendingToken = pendingTransportCheckToken.get() if (pendingToken == token) { - Log.w(TAG, "transport_sync: phase=check_timeout generation=$generation token=$token") + Log.w(TAG, "Transport check timed out") reportLanNegotiationFailure("check_timeout") sendTransportNominate("relay", generation, "check_timeout") } @@ -1232,13 +1188,12 @@ object WebSocketUtil { put("reason", reason) }) }.toString() - Log.d(TAG, "transport_sync: phase=check source=android generation=$generation token=$token") return sendMessage(payload) } fun sendTransportCheckAck(generation: Long, token: String): Boolean { if (!isTransportGenerationActive(generation)) { - Log.w(TAG, "transport_sync: phase=check_ack_drop generation=$generation reason=inactive_generation") + Log.w(TAG, "Dropping transport check-ack for inactive generation") return false } val payload = JSONObject().apply { @@ -1250,19 +1205,16 @@ object WebSocketUtil { put("ts", System.currentTimeMillis()) }) }.toString() - Log.d(TAG, "transport_sync: phase=check_ack source=android generation=$generation token=$token") return sendMessage(payload) } fun onTransportCheckAck(generation: Long, token: String) { if (!isTransportGenerationActive(generation)) { - Log.d(TAG, "transport_sync: phase=check_ack_drop generation=$generation reason=inactive_generation") return } val pendingGeneration = pendingTransportCheckGeneration.get() val pendingToken = pendingTransportCheckToken.get() if (pendingGeneration != generation || pendingToken != token) { - Log.d(TAG, "transport_sync: phase=check_ack_stale generation=$generation token=$token") return } transportCheckTimeoutJob?.cancel() @@ -1271,22 +1223,21 @@ object WebSocketUtil { pendingTransportCheckGeneration.set(0L) reportLanNegotiationSuccess("check_ack") if (!isConnected()) { - Log.w(TAG, "transport_sync: phase=check_ack_drop generation=$generation reason=lan_not_connected") + 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") - Log.i(TAG, "transport_sync: phase=nominate_lan source=android generation=$generation reason=check_ack") } fun sendTransportNominate(path: String, generation: Long, reason: String): Boolean { if (!isTransportGenerationActive(generation)) { - Log.w(TAG, "transport_sync: phase=nominate_drop generation=$generation reason=inactive_generation") + Log.w(TAG, "Dropping transport nominate for inactive generation") return false } if (path == "lan" && !isTransportGenerationValidated(generation)) { - Log.w(TAG, "transport_sync: phase=nominate_drop generation=$generation reason=not_validated") + Log.w(TAG, "Dropping LAN nominate because generation is not validated") return false } val payload = JSONObject().apply { @@ -1299,7 +1250,6 @@ object WebSocketUtil { put("reason", reason) }) }.toString() - Log.d(TAG, "transport_sync: phase=nominate source=android generation=$generation path=$path reason=$reason") return sendMessage(payload) } From 37e30045713a6ba04d12c2f231587006e33b1904 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 16:24:35 +0100 Subject: [PATCH 23/29] feat: Mark AirBridge Relay as Beta in UI - Updated the title in `AirBridgeCard` to "AirBridge Relay (Beta)" to reflect the feature's current status. --- .../airsync/presentation/ui/components/cards/AirBridgeCard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 9bb7b145..5600a3ac 100644 --- 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 @@ -87,7 +87,7 @@ fun AirBridgeCard(context: Context) { verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - Text("AirBridge Relay", style = MaterialTheme.typography.titleMedium) + 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), From 147a6a6eeb3819b3d86ca250f04dc64fe2336633 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 16:26:22 +0100 Subject: [PATCH 24/29] feat: readd URLDecoder import to MainActivity - Redded `java.net.URLDecoder` import to `MainActivity.kt`. --- app/src/main/java/com/sameerasw/airsync/MainActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 32bd0b94..e3b04c53 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -65,6 +65,7 @@ import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import java.net.URLDecoder object AdbDiscoveryHolder { private var discovery: AdbMdnsDiscovery? = null From 13eae8ac1a19c670146ce4ac7a9352f282031c74 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Thu, 26 Mar 2026 16:37:26 +0100 Subject: [PATCH 25/29] readded removed logs --- .../java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 5c8aebdf..b377eda2 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -427,6 +427,7 @@ object WebSocketUtil { } override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "RAW WebSocket message received: ${text}...") val decryptedMessage = currentSymmetricKey?.let { key -> CryptoUtil.decryptMessage(text, key) } ?: text @@ -672,6 +673,7 @@ object WebSocketUtil { fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) return if (isSocketOpen.get() && webSocket != null) { + Log.d(TAG, "Sending message: $message") val messageToSend = currentSymmetricKey?.let { key -> CryptoUtil.encryptMessage(message, key) } ?: message @@ -866,6 +868,11 @@ object WebSocketUtil { // Match by name within the discovery list val discoveryMatch = discoveredList.find { it.name == last.name } if (discoveryMatch != null) { + Log.d( + TAG, + "Discovery found target device: ${discoveryMatch.name} with IPs: ${discoveryMatch.ips}" + ) + val all = ds.getAllNetworkDeviceConnections().first() val targetConnection = all.firstOrNull { it.deviceName == last.name } @@ -873,6 +880,10 @@ object WebSocketUtil { val ips = discoveryMatch.ips.joinToString(",") val port = targetConnection.port.toIntOrNull() ?: 6996 + Log.d( + TAG, + "Smart Auto-reconnect attempting parallel connections to $ips:$port" + ) connect( context = context, ipAddress = ips, From e49fb1b568a1d0dca1f4ab184131be0c3fad9211 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Mon, 30 Mar 2026 19:27:39 +0200 Subject: [PATCH 26/29] revert: reverted logging for WebSocket decryption failures - Updated the `onMessage` callback in `WebSocketUtil` to log an error when a message fails to decrypt using the current symmetric key. --- .../main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 b377eda2..131a00e1 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -429,7 +429,9 @@ object WebSocketUtil { override fun onMessage(webSocket: WebSocket, text: String) { Log.d(TAG, "RAW WebSocket message received: ${text}...") val decryptedMessage = currentSymmetricKey?.let { key -> - CryptoUtil.decryptMessage(text, key) + val decrypted = CryptoUtil.decryptMessage(text, key) + if (decrypted == null) Log.e(TAG, "FAILED TO DECRYPT WebSocket message!") + decrypted } ?: text if (!handshakeCompleted.get()) { From c5aa3c407c06b661fe6381270fdc08152568918c Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 1 Apr 2026 20:52:32 +0200 Subject: [PATCH 27/29] feat: allow notification updates to be sent when AirBridge relay is active --- .../sameerasw/airsync/service/MediaNotificationListener.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 0ae06ea9..06a24ca4 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -19,6 +19,7 @@ import com.sameerasw.airsync.domain.model.MediaInfo import com.sameerasw.airsync.utils.JsonUtil import com.sameerasw.airsync.utils.NotificationDismissalUtil import com.sameerasw.airsync.utils.SyncManager +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -516,7 +517,7 @@ class MediaNotificationListener : NotificationListenerService() { } // Build and send update - if (WebSocketUtil.isConnected()) { + if (WebSocketUtil.isConnected() || AirBridgeClient.isRelayActive()) { val update = JsonUtil.toSingleLine( JsonUtil.createNotificationUpdateJson( id, @@ -655,7 +656,7 @@ class MediaNotificationListener : NotificationListenerService() { Log.d(TAG, "Preparing to send notification: $notificationJson") - if (WebSocketUtil.isConnected()) { + if (WebSocketUtil.isConnected() || AirBridgeClient.isRelayActive()) { Log.d(TAG, "Sending notification via WebSocket") val success = WebSocketUtil.sendMessage(notificationJson) if (success) { From 4b0ca48ae5e1d18818ec5b593dc50149e9918fe8 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 1 Apr 2026 21:08:51 +0200 Subject: [PATCH 28/29] refactor: consolidate connection checks by introducing WebSocketUtil.isConnectedOrRelayActive() --- .../com/sameerasw/airsync/service/AirSyncTileService.kt | 5 +++-- .../com/sameerasw/airsync/service/ClipboardTileService.kt | 5 +++-- .../airsync/service/MediaNotificationListener.kt | 5 ++--- .../com/sameerasw/airsync/utils/ClipboardSyncManager.kt | 2 +- .../com/sameerasw/airsync/utils/MacDeviceStatusManager.kt | 4 ++-- .../main/java/com/sameerasw/airsync/utils/SyncManager.kt | 4 ++-- .../java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 8 ++++++++ 7 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt index e7fe404d..e3cc6c78 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -10,6 +10,7 @@ import android.util.Log import com.sameerasw.airsync.MainActivity import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -86,7 +87,7 @@ class AirSyncTileService : TileService() { super.onClick() serviceScope.launch { - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() val isAuto = WebSocketUtil.isAutoReconnecting() if (isAuto && !isConnected) { @@ -174,7 +175,7 @@ class AirSyncTileService : TileService() { private suspend fun updateTileState() { try { - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() val isAuto = WebSocketUtil.isAutoReconnecting() val isConnecting = WebSocketUtil.isConnecting() val lastDevice = dataStoreManager.getLastConnectedDevice().first() diff --git a/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt b/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt index 6681d08d..f2458b00 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt @@ -9,6 +9,7 @@ import android.widget.Toast import androidx.annotation.RequiresApi import com.sameerasw.airsync.R import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,7 +49,7 @@ class ClipboardTileService : TileService() { override fun onStartListening() { super.onStartListening() serviceScope.launch { - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() updateTileState(isConnected) } } @@ -57,7 +58,7 @@ class ClipboardTileService : TileService() { super.onClick() serviceScope.launch { - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() if (isConnected) { try { val intent = android.content.Intent( diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 06a24ca4..03759c50 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -19,7 +19,6 @@ import com.sameerasw.airsync.domain.model.MediaInfo import com.sameerasw.airsync.utils.JsonUtil import com.sameerasw.airsync.utils.NotificationDismissalUtil import com.sameerasw.airsync.utils.SyncManager -import com.sameerasw.airsync.utils.AirBridgeClient import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -517,7 +516,7 @@ class MediaNotificationListener : NotificationListenerService() { } // Build and send update - if (WebSocketUtil.isConnected() || AirBridgeClient.isRelayActive()) { + if (WebSocketUtil.isConnectedOrRelayActive()) { val update = JsonUtil.toSingleLine( JsonUtil.createNotificationUpdateJson( id, @@ -656,7 +655,7 @@ class MediaNotificationListener : NotificationListenerService() { Log.d(TAG, "Preparing to send notification: $notificationJson") - if (WebSocketUtil.isConnected() || AirBridgeClient.isRelayActive()) { + if (WebSocketUtil.isConnectedOrRelayActive()) { Log.d(TAG, "Sending notification via WebSocket") val success = WebSocketUtil.sendMessage(notificationJson) if (success) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt index 28acc8ae..f4425afa 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt @@ -173,7 +173,7 @@ object ClipboardSyncManager { true } // Only for Plus and while connected - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() val last = try { dataStoreManager.getLastConnectedDevice().first() } catch (_: Exception) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index fb930087..10912cf1 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -73,7 +73,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { val ds = DataStoreManager(context) val isMediaControlsEnabled = ds.getMacMediaControlsEnabled().first() - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() val isEssentialsEnabled = ds.getEssentialsConnectionEnabled().first() if (isConnected && isMediaControlsEnabled && (title.isNotEmpty() || artist.isNotEmpty() || isPlaying)) { @@ -175,7 +175,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { try { // Check current state - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnectedOrRelayActive() val currentStatus = _macDeviceStatus.value if (isConnected && currentStatus != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 852e5ce2..b619010e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -43,7 +43,7 @@ object SyncManager { while (isActive && isSyncing.get()) { try { // Check if WebSocket is connected and sync is enabled - if (WebSocketUtil.isConnected()) { + if (WebSocketUtil.isConnectedOrRelayActive()) { val dataStoreManager = DataStoreManager(context) val isSyncEnabled = dataStoreManager.getNotificationSyncEnabled().first() @@ -110,7 +110,7 @@ object SyncManager { shouldSync = true // First time } - if (shouldSync && WebSocketUtil.isConnected()) { + if (shouldSync && WebSocketUtil.isConnectedOrRelayActive()) { val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) val success = WebSocketUtil.sendMessage(statusJson) 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 131a00e1..f8e33966 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -782,6 +782,14 @@ object WebSocketUtil { return isConnecting.get() } + /** + * Returns true if any transport (LAN or AirBridge relay) is available for messaging. + * Use this instead of [isConnected] when you want to send data regardless of transport. + */ + fun isConnectedOrRelayActive(): Boolean { + return isConnected.get() || AirBridgeClient.isRelayActive() + } + private val lastSyncTimeCache = java.util.concurrent.atomic.AtomicLong(0L) private fun updateLastSyncTime(context: Context) { From 5d347d21a49421309864b4cff767e46be3581616 Mon Sep 17 00:00:00 2001 From: Corrado Belmonte Date: Wed, 1 Apr 2026 21:26:31 +0200 Subject: [PATCH 29/29] refactor: improve IP validation logic, harden symmetric key handling, and remove plaintext relay message fallback --- .../components/cards/ConnectionStatusCard.kt | 1 - .../airsync/utils/AirBridgeClient.kt | 21 ++++++++++--------- .../airsync/utils/WebSocketMessageHandler.kt | 16 +++++++++----- 3 files changed, 22 insertions(+), 16 deletions(-) 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 dd246330..e1637df8 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,7 +11,6 @@ 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 diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt index b1f89a7c..4e1108e9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt @@ -168,7 +168,15 @@ object AirBridgeClient { * Allows LAN flow to explicitly refresh the relay key, so transport switching is seamless. */ fun updateSymmetricKey(base64Key: String?) { - symmetricKey = base64Key?.let { CryptoUtil.decodeKey(it) } + if (base64Key == null) { + symmetricKey = null + return + } + try { + symmetricKey = CryptoUtil.decodeKey(base64Key) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode symmetric key; keeping previous key", e) + } } // Resolves the symmetric key for relay encryption/decryption. @@ -544,15 +552,8 @@ object AirBridgeClient { } 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 - } + Log.e(TAG, "SECURITY: Decryption failed or no key available. Dropping relay message.") + return } appContext?.let { ctx -> 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 292f23fd..366da8e2 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -429,7 +429,6 @@ object WebSocketMessageHandler { 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 { @@ -487,12 +486,19 @@ object WebSocketMessageHandler { 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 + if (parts.size != 4) return false + val first = parts[0].toIntOrNull() ?: return false val second = parts[1].toIntOrNull() ?: return false - return second in 16..31 + // 10.0.0.0/8 + if (first == 10) return true + // 192.168.0.0/16 + if (first == 192 && second == 168) return true + // 172.16.0.0/12 + if (first == 172 && second in 16..31) return true + // 100.64.0.0/10 (CGNAT — Tailscale, ZeroTier) + if (first == 100 && second in 64..127) return true + return false } private fun extractSanitizedCandidates(data: JSONObject?): CandidateExtractionResult {