diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 8465d1c..b1876a9 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -80,9 +80,20 @@ object AdbDiscoveryHolder { } } +private data class ConnectionLaunchPayload( + val ip: String? = null, + val port: String? = null, + val pcName: String? = null, + val isPlus: Boolean = false, + val symmetricKey: String? = null, + val showConnectionDialog: Boolean = false, + val requestNonce: Long = 0L +) + class MainActivity : ComponentActivity() { // Flag to keep splash screen visible during app initialization private var isAppReady = false + private var launchPayload by mutableStateOf(ConnectionLaunchPayload()) // Permission launcher for Android 13+ notification permission private val notificationPermissionLauncher = registerForActivityResult( @@ -340,33 +351,7 @@ class MainActivity : ComponentActivity() { } } - val data: android.net.Uri? = intent?.data - val ip = data?.host - val port = data?.port?.takeIf { it != -1 }?.toString() - - // Parse QR code parameters - var pcName: String? = null - var isPlus = false - var symmetricKey: 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 - } - pcName = paramMap["name"]?.let { URLDecoder.decode(it, "UTF-8") } - isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false - symmetricKey = paramMap["key"] - } - } - - val isFromQrScan = data != null + launchPayload = parseConnectionLaunch(intent) setContent { val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel = @@ -396,12 +381,13 @@ class MainActivity : ComponentActivity() { ) { composable("main") { AirSyncMainScreen( - initialIp = ip, - initialPort = port, - showConnectionDialog = isFromQrScan, - pcName = pcName, - isPlus = isPlus, - symmetricKey = symmetricKey + initialIp = launchPayload.ip, + initialPort = launchPayload.port, + showConnectionDialog = launchPayload.showConnectionDialog, + pcName = launchPayload.pcName, + isPlus = launchPayload.isPlus, + symmetricKey = launchPayload.symmetricKey, + requestNonce = launchPayload.requestNonce ) } } @@ -548,6 +534,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) + setIntent(intent) // Handle Notes Role intent handleNotesRoleIntent(intent) @@ -562,8 +549,14 @@ class MainActivity : ComponentActivity() { } startActivity(qrScannerIntent) finish() + return } } + + val updatedLaunchPayload = parseConnectionLaunch(intent) + if (updatedLaunchPayload.showConnectionDialog) { + launchPayload = updatedLaunchPayload + } } /** @@ -647,4 +640,54 @@ class MainActivity : ComponentActivity() { ContentCaptureManager.launchContentCapture(contentCaptureLauncher) } + + private fun parseConnectionLaunch(intent: Intent?): ConnectionLaunchPayload { + val uri = intent?.data ?: return ConnectionLaunchPayload() + if (uri.scheme != "airsync") return ConnectionLaunchPayload() + + var ip = uri.host ?: "" + var port = uri.port.takeIf { it != -1 }?.toString() ?: "" + + if (ip.isEmpty() || port.isEmpty()) { + val authority = uri.toString().substringAfter("://").substringBefore("?") + if (authority.contains(":")) { + ip = authority.substringBeforeLast(":") + port = authority.substringAfterLast(":") + } else { + ip = authority + } + } + + if (ip.isEmpty() || port.isEmpty()) { + return ConnectionLaunchPayload() + } + + var pcName: String? = null + var isPlus = false + var symmetricKey: String? = null + + val queryPart = uri.toString().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 + } + pcName = paramMap["name"]?.let { URLDecoder.decode(it, "UTF-8") } + isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false + symmetricKey = paramMap["key"] + } + + return ConnectionLaunchPayload( + ip = ip, + port = port, + pcName = pcName, + isPlus = isPlus, + symmetricKey = symmetricKey, + showConnectionDialog = true, + requestNonce = System.currentTimeMillis() + ) + } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index 0e5672e..0398530 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 @@ -15,6 +15,7 @@ import com.sameerasw.airsync.domain.model.NotificationApp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import org.json.JSONArray import org.json.JSONObject val Context.dataStore: DataStore by preferencesDataStore(name = "airsync_settings") @@ -604,11 +605,38 @@ class DataStoreManager(private val context: Context) { } } + private fun parseNetworkConnections(jsonString: String): Map { + return try { + val json = JSONObject(jsonString) + buildMap { + json.keys().forEach { key -> + put(key, json.getString(key)) + } + } + } catch (_: Exception) { + emptyMap() + } + } + + private fun parseStringList(jsonString: String): List { + return try { + val json = JSONArray(jsonString) + List(json.length()) { index -> json.getString(index) } + } catch (_: Exception) { + emptyList() + } + } + + private fun stringifyStringList(values: Iterable): String { + return JSONArray(NetworkDeviceConnection.rankIps(values)).toString() + } + // Network-aware device connections suspend fun saveNetworkDeviceConnection( deviceName: String, ourIp: String, clientIp: String, + candidateIps: List = emptyList(), port: String, isPlus: Boolean, symmetricKey: String?, @@ -616,33 +644,22 @@ class DataStoreManager(private val context: Context) { deviceType: String? = null ) { context.dataStore.edit { preferences -> - // Load existing connections for this device val existingConnectionsJson = preferences[stringPreferencesKey("${NETWORK_CONNECTIONS_PREFIX}${deviceName}")] ?: "{}" - val existingConnections = try { - val json = JSONObject(existingConnectionsJson) - val map = mutableMapOf() - json.keys().forEach { key -> - map[key] = json.getString(key) - } - map - } catch (_: Exception) { - mutableMapOf() - } + val existingConnections = parseNetworkConnections(existingConnectionsJson).toMutableMap() - // Add/update the new connection existingConnections[ourIp] = clientIp - // Convert back to JSON val updatedJson = JSONObject(existingConnections).toString() - // Save device info preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_name")] = deviceName preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_port")] = port preferences[booleanPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_plus")] = isPlus + preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_candidate_ips")] = + stringifyStringList(existingConnections.values + candidateIps + listOf(clientIp)) symmetricKey?.let { preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_symmetric_key")] = it @@ -673,6 +690,9 @@ class DataStoreManager(private val context: Context) { ?: false val symmetricKey = preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_symmetric_key")] + val candidateIpsJson = + preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_candidate_ips")] + ?: "[]" val model = preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_model")] val deviceType = @@ -685,20 +705,10 @@ class DataStoreManager(private val context: Context) { ?: "{}" if (name != null && port != null) { - val connections = try { - val json = JSONObject(connectionsJson) - val map = mutableMapOf() - json.keys().forEach { key -> - map[key] = json.getString(key) - } - map - } catch (_: Exception) { - emptyMap() - } - NetworkDeviceConnection( deviceName = name, - networkConnections = connections, + networkConnections = parseNetworkConnections(connectionsJson), + candidateIps = parseStringList(candidateIpsJson), port = port, lastConnected = lastConnected, isPlus = isPlus, @@ -737,6 +747,9 @@ class DataStoreManager(private val context: Context) { ?: false val symmetricKey = preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_symmetric_key")] + val candidateIpsJson = + preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_candidate_ips")] + ?: "[]" val model = preferences[stringPreferencesKey("${NETWORK_DEVICES_PREFIX}${deviceName}_model")] val deviceType = @@ -749,21 +762,11 @@ class DataStoreManager(private val context: Context) { ?: "{}" if (name != null && port != null) { - val connections = try { - val json = JSONObject(connectionsJson) - val map = mutableMapOf() - json.keys().forEach { key -> - map[key] = json.getString(key) - } - map - } catch (_: Exception) { - emptyMap() - } - devices.add( NetworkDeviceConnection( deviceName = name, - networkConnections = connections, + networkConnections = parseNetworkConnections(connectionsJson), + candidateIps = parseStringList(candidateIpsJson), port = port, lastConnected = lastConnected, isPlus = isPlus, diff --git a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt index 02c5ee5..a04acaf 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt @@ -80,6 +80,7 @@ class AirSyncRepositoryImpl( deviceName: String, ourIp: String, clientIp: String, + candidateIps: List, port: String, isPlus: Boolean, symmetricKey: String?, @@ -90,6 +91,7 @@ class AirSyncRepositoryImpl( deviceName, ourIp, clientIp, + candidateIps, port, isPlus, symmetricKey, @@ -295,4 +297,4 @@ class AirSyncRepositoryImpl( override fun isQuickShareEnabled(): Flow { return dataStoreManager.isQuickShareEnabled() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/ConnectionTransport.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/ConnectionTransport.kt new file mode 100644 index 0000000..d019059 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/ConnectionTransport.kt @@ -0,0 +1,32 @@ +package com.sameerasw.airsync.domain.model + +enum class ConnectionTransport { + LOCAL, + EXTENDED, + UNKNOWN; + + companion object { + fun fromIp(ipAddress: String?): ConnectionTransport { + val ip = ipAddress?.trim().orEmpty() + if (ip.isEmpty()) return UNKNOWN + + if (ip.startsWith("192.168.") || ip.startsWith("10.") || ip.startsWith("127.") || ip.startsWith("169.254.")) { + return LOCAL + } + + if (ip.startsWith("172.")) { + val parts = ip.split(".") + val secondOctet = parts.getOrNull(1)?.toIntOrNull() + if (secondOctet != null && secondOctet in 16..31) { + return LOCAL + } + } + + if (ip.startsWith("100.")) { + return EXTENDED + } + + return EXTENDED + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/NetworkDeviceConnection.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/NetworkDeviceConnection.kt index 45996d2..a18b9f5 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/NetworkDeviceConnection.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/NetworkDeviceConnection.kt @@ -3,6 +3,7 @@ package com.sameerasw.airsync.domain.model data class NetworkDeviceConnection( val deviceName: String, val networkConnections: Map, + val candidateIps: List = emptyList(), val port: String, val lastConnected: Long, val isPlus: Boolean, @@ -11,11 +12,52 @@ data class NetworkDeviceConnection( val model: String? = null, val deviceType: String? = null ) { + companion object { + fun isTailscaleIp(ip: String): Boolean = ip.startsWith("100.") + + fun isPreferredLanIp(ip: String): Boolean { + if (ip.startsWith("192.168.") || ip.startsWith("10.")) return true + if (!ip.startsWith("172.")) return false + + val parts = ip.split(".") + if (parts.size < 2) return false + val secondOctet = parts[1].toIntOrNull() ?: return false + return secondOctet in 16..31 + } + + fun rankIps(ips: Iterable): List { + return ips + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .sortedWith( + compareBy( + { !isPreferredLanIp(it) }, + { isTailscaleIp(it) }, + { it } + ) + ) + } + } + // get client IP for current network fun getClientIpForNetwork(ourIp: String): String? { return networkConnections[ourIp] } + fun getOrderedReconnectCandidates(ourIp: String?): List { + val prioritized = mutableListOf() + + if (!ourIp.isNullOrBlank()) { + networkConnections[ourIp]?.let { prioritized.add(it) } + } + + prioritized.addAll(networkConnections.values) + prioritized.addAll(candidateIps) + + return rankIps(prioritized) + } + // create ConnectedDevice for current network fun toConnectedDevice(ourIp: String): ConnectedDevice? { val clientIp = getClientIpForNetwork(ourIp) @@ -32,4 +74,4 @@ data class NetworkDeviceConnection( ) } else null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index 79c9397..f31ebe7 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 connectionTransport: ConnectionTransport = ConnectionTransport.UNKNOWN, val connectingDeviceId: String? = null, val isDeviceDiscoveryEnabled: Boolean = true, val shouldShowRatingPrompt: Boolean = false, @@ -50,4 +51,4 @@ data class UiState( val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, val isQuickShareEnabled: Boolean = false -) \ No newline at end of file +) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt index 32f2def..40c043d 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt @@ -35,6 +35,7 @@ interface AirSyncRepository { deviceName: String, ourIp: String, clientIp: String, + candidateIps: List = emptyList(), port: String, isPlus: Boolean, symmetricKey: String?, @@ -132,4 +133,4 @@ interface AirSyncRepository { // Quick Share (receiving) suspend fun setQuickShareEnabled(enabled: Boolean) fun isQuickShareEnabled(): Flow -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index 54f03e3..cc02157 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R +import com.sameerasw.airsync.domain.model.ConnectionTransport import com.sameerasw.airsync.domain.model.ConnectedDevice import com.sameerasw.airsync.domain.model.UiState import com.sameerasw.airsync.presentation.ui.components.RotatingAppIcon @@ -142,6 +143,32 @@ fun ConnectionStatusCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + val transport = uiState.connectionTransport + val transportColor = when (transport) { + ConnectionTransport.LOCAL -> MaterialTheme.colorScheme.primary + ConnectionTransport.EXTENDED -> MaterialTheme.colorScheme.tertiary + ConnectionTransport.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = transportColor + ) { + Text( + text = when (transport) { + ConnectionTransport.LOCAL -> "LAN" + ConnectionTransport.EXTENDED -> "Internet" + ConnectionTransport.UNKNOWN -> "Unknown" + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = when (transport) { + ConnectionTransport.UNKNOWN -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onPrimary + } + ) + } + val ips = uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } ips.forEach { ip -> @@ -240,4 +267,4 @@ fun ConnectionStatusCard( } -} \ No newline at end of file +} 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 0834e89..cf61162 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 @@ -144,6 +144,7 @@ fun AirSyncMainScreen( pcName: String? = null, isPlus: Boolean = false, symmetricKey: String? = null, + requestNonce: Long = 0L, onNavigateToApps: () -> Unit = {}, onTitleChange: (String) -> Unit = {} ) { @@ -164,7 +165,6 @@ fun AirSyncMainScreen( val lifecycle = LocalLifecycleOwner.current.lifecycle val connectScrollState = rememberScrollState() val settingsScrollState = rememberScrollState() - var hasProcessedQrDialog by remember { mutableStateOf(false) } var hasAppliedInitialTab by remember { mutableStateOf(false) } var isWelcomeDismissed by rememberSaveable { mutableStateOf(false) } var hasSeenWelcomeThisSession by rememberSaveable { mutableStateOf(false) } @@ -328,6 +328,7 @@ fun AirSyncMainScreen( val connected = result viewModel.setConnectionStatus(isConnected = connected, isConnecting = false) if (connected) { + viewModel.setDialogVisible(false) viewModel.setResponse("Connected successfully!") val plusStatus = uiState.lastConnectedDevice?.isPlus ?: isPlus viewModel.saveLastConnectedDevice(pcName, plusStatus, uiState.symmetricKey) @@ -503,15 +504,7 @@ fun AirSyncMainScreen( } LaunchedEffect(Unit) { - viewModel.initializeState( - context, - initialIp, - initialPort, - showConnectionDialog && !hasProcessedQrDialog, - pcName, - isPlus, - symmetricKey - ) + viewModel.initializeState(context) // Start network monitoring for dynamic Wi-Fi changes viewModel.startNetworkMonitoring(context) @@ -520,6 +513,18 @@ fun AirSyncMainScreen( viewModel.refreshPermissions(context) } + LaunchedEffect(requestNonce) { + if (requestNonce != 0L && showConnectionDialog) { + viewModel.applyConnectionLaunch( + initialIp = initialIp, + initialPort = initialPort, + pcName = pcName, + isPlus = isPlus, + symmetricKey = symmetricKey + ) + } + } + // Refresh permissions when app resumes from pause DisposableEffect(lifecycle) { val lifecycleObserver = androidx.lifecycle.LifecycleEventObserver { _, event -> @@ -534,19 +539,6 @@ fun AirSyncMainScreen( } } - // Mark QR dialog as processed when it's shown or when already connected - LaunchedEffect(showConnectionDialog, uiState.isConnected) { - if (showConnectionDialog) { - if (uiState.isConnected) { - // If already connected, don't show dialog - hasProcessedQrDialog = true - } else if (uiState.isDialogVisible) { - // Dialog is being shown, mark as processed - hasProcessedQrDialog = true - } - } - } - // Refresh permissions when returning from settings LaunchedEffect(uiState.showPermissionDialog) { if (!uiState.showPermissionDialog) { @@ -918,7 +910,10 @@ fun AirSyncMainScreen( .fillMaxWidth() .clickable { HapticUtil.performClick(haptics) - viewModel.updateIpAddress(device.getBestIp()) + viewModel.updateIpAddress( + device.getOrderedIps() + .joinToString(",") + ) viewModel.updatePort(device.port.toString()) viewModel.updateManualPcName( device.name diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index f6be08c..c5e1e1a 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.data.repository.AirSyncRepositoryImpl +import com.sameerasw.airsync.domain.model.ConnectionTransport import com.sameerasw.airsync.domain.model.ConnectedDevice import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.NetworkDeviceConnection @@ -94,11 +95,13 @@ class AirSyncViewModel( // Connection status listener for WebSocket updates private val connectionStatusListener: (Boolean) -> Unit = { isConnected -> viewModelScope.launch { + val activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null _uiState.value = _uiState.value.copy( isConnected = isConnected, isConnecting = false, response = if (isConnected) "Connected successfully!" else "Disconnected", - activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null, + activeIp = activeIp, + connectionTransport = ConnectionTransport.fromIp(activeIp), macDeviceStatus = if (isConnected) _uiState.value.macDeviceStatus else null ) @@ -243,19 +246,13 @@ class AirSyncViewModel( } fun initializeState( - context: Context, - initialIp: String? = null, - initialPort: String? = null, - showConnectionDialog: Boolean = false, - pcName: String? = null, - isPlus: Boolean = false, - symmetricKey: String? = null + context: Context ) { appContext = context.applicationContext viewModelScope.launch { // Load saved values - val savedIp = initialIp ?: repository.getIpAddress().first() - val savedPort = initialPort ?: repository.getPort().first() + val savedIp = repository.getIpAddress().first() + val savedPort = repository.getPort().first() val savedDeviceName = repository.getDeviceName().first() val lastConnected = repository.getLastConnectedDevice().first() val isNotificationSyncEnabled = repository.getNotificationSyncEnabled().first() @@ -311,13 +308,12 @@ class AirSyncViewModel( // Check current WebSocket connection status val currentlyConnected = WebSocketUtil.isConnected() + val activeIp = if (currentlyConnected) WebSocketUtil.currentIpAddress else null _uiState.value = _uiState.value.copy( ipAddress = savedIp, port = savedPort, deviceNameInput = deviceName, - // Only show dialog if not already connected and showConnectionDialog is true - isDialogVisible = showConnectionDialog && !currentlyConnected, missingPermissions = missingPermissions, isNotificationEnabled = isNotificationEnabled, lastConnectedDevice = deviceToShow, @@ -326,7 +322,9 @@ class AirSyncViewModel( isClipboardSyncEnabled = isClipboardSyncEnabled, isAutoReconnectEnabled = isAutoReconnectEnabled, isConnected = currentlyConnected, - symmetricKey = symmetricKey ?: lastConnectedSymmetricKey, + activeIp = activeIp, + connectionTransport = ConnectionTransport.fromIp(activeIp), + symmetricKey = lastConnectedSymmetricKey, isContinueBrowsingEnabled = isContinueBrowsingEnabled, isSendNowPlayingEnabled = isSendNowPlayingEnabled, isKeepPreviousLinkEnabled = isKeepPreviousLinkEnabled, @@ -345,21 +343,6 @@ class AirSyncViewModel( updateRatingPromptDisplay() - // If we have PC name from QR code and not already connected, store it temporarily for the dialog - if (pcName != null && showConnectionDialog && !currentlyConnected) { - _uiState.value = _uiState.value.copy( - lastConnectedDevice = ConnectedDevice( - name = pcName, - ipAddress = savedIp, - port = savedPort, - lastConnected = System.currentTimeMillis(), - isPlus = isPlus, - symmetricKey = symmetricKey - ) - ) - } - - // Start observing device changes for real-time updates startObservingDeviceChanges(context) @@ -401,6 +384,41 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(manualIsPlus = isPlus) } + fun applyConnectionLaunch( + initialIp: String?, + initialPort: String?, + pcName: String?, + isPlus: Boolean, + symmetricKey: String? + ) { + val ip = initialIp?.trim().orEmpty() + val port = initialPort?.trim().orEmpty() + if (ip.isEmpty() || port.isEmpty()) return + + val currentConnection = WebSocketUtil.isConnected() + val deviceName = pcName ?: _uiState.value.lastConnectedDevice?.name ?: "My Mac" + val launchedDevice = ConnectedDevice( + name = deviceName, + ipAddress = ip, + port = port, + lastConnected = System.currentTimeMillis(), + isPlus = isPlus, + symmetricKey = symmetricKey + ) + + _uiState.value = _uiState.value.copy( + ipAddress = ip, + port = port, + manualPcName = deviceName, + manualIsPlus = isPlus, + symmetricKey = symmetricKey, + lastConnectedDevice = launchedDevice, + isDialogVisible = !currentConnection, + showAuthFailureDialog = false, + authFailureMessage = "" + ) + } + fun prepareForManualConnection() { val manualDevice = ConnectedDevice( name = _uiState.value.manualPcName.ifEmpty { "My Mac/PC" }, @@ -451,9 +469,12 @@ class AirSyncViewModel( } fun setConnectionStatus(isConnected: Boolean, isConnecting: Boolean = false) { + val activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null _uiState.value = _uiState.value.copy( isConnected = isConnected, isConnecting = isConnecting, + activeIp = activeIp, + connectionTransport = ConnectionTransport.fromIp(activeIp), connectingDeviceId = if (!isConnecting) null else _uiState.value.connectingDeviceId ) } @@ -479,14 +500,21 @@ class AirSyncViewModel( viewModelScope.launch { val deviceName = pcName ?: "My Mac" val ourIp = _deviceInfo.value.localIp - val clientIp = _uiState.value.ipAddress + val currentConnectedIp = WebSocketUtil.currentIpAddress?.trim().orEmpty() + val candidateIps = NetworkDeviceConnection.rankIps( + _uiState.value.ipAddress.split(",") + currentConnectedIp + ) + val clientIp = currentConnectedIp.ifBlank { candidateIps.firstOrNull().orEmpty() } val port = _uiState.value.port + if (clientIp.isBlank()) return@launch + // Save using network-aware storage repository.saveNetworkDeviceConnection( deviceName, ourIp, clientIp, + candidateIps, port, isPlus, symmetricKey @@ -533,7 +561,18 @@ class AirSyncViewModel( .filter { it.getClientIpForNetwork(ourIp) != null } .maxByOrNull { it.lastConnected } - return networkDevice?.toConnectedDevice(ourIp) + if (networkDevice != null) { + return networkDevice.toConnectedDevice(ourIp) + } + + val last = _uiState.value.lastConnectedDevice ?: return null + val fallback = _networkDevices.value + .filter { it.deviceName == last.name } + .maxByOrNull { it.lastConnected } + ?: return null + + val fallbackIp = fallback.getOrderedReconnectCandidates(ourIp).firstOrNull() ?: return null + return last.copy(ipAddress = fallbackIp, port = fallback.port, symmetricKey = fallback.symmetricKey) } fun setNotificationSyncEnabled(enabled: Boolean) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt index d9bd2e0..61e989e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -2,6 +2,7 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log +import com.sameerasw.airsync.domain.model.NetworkDeviceConnection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -31,8 +32,10 @@ data class DiscoveredDevice( // check if it has a Tailscale IP fun hasTailscaleIp(): Boolean = ips.any { it.startsWith("100.") } + fun getOrderedIps(): List = NetworkDeviceConnection.rankIps(ips) + // Best IP for connection - fun getBestIp(): String = ips.find { !it.startsWith("100.") } ?: ips.firstOrNull() ?: "" + fun getBestIp(): String = getOrderedIps().firstOrNull() ?: "" } enum class DiscoveryMode { @@ -482,7 +485,7 @@ object UDPDiscoveryManager { } catch (e: Exception) { Log.e(TAG, "Error getting network interfaces: ${e.message}") } - return ips + return NetworkDeviceConnection.rankIps(ips) } private fun startPruning() { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt index 418449b..c3b3dcf 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WakeupHandler.kt @@ -58,6 +58,7 @@ object WakeupHandler { deviceName = macName, ourIp = ourIp, clientIp = macIp, + candidateIps = listOf(macIp), port = macPort.toString(), isPlus = true, symmetricKey = encryptionKey, 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 31bc146..c4e5713 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -8,6 +8,7 @@ import android.widget.Toast import com.sameerasw.airsync.BuildConfig import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.data.repository.AirSyncRepositoryImpl +import com.sameerasw.airsync.domain.model.NetworkDeviceConnection import com.sameerasw.airsync.service.MediaNotificationListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -581,12 +582,15 @@ object WebSocketMessageHandler { val clientIp = last.ipAddress val port = last.port val symmetricKey = last.symmetricKey + val candidateIps = + NetworkDeviceConnection.rankIps(last.ipAddress.split(",") + clientIp) if (clientIp.isNotBlank() && ourIp.isNotBlank()) { ds.saveNetworkDeviceConnection( deviceName = if (macName.isNotBlank()) macName else last.name, ourIp = ourIp, clientIp = clientIp, + candidateIps = candidateIps, port = port, isPlus = isPlus, symmetricKey = symmetricKey, @@ -900,4 +904,3 @@ object WebSocketMessageHandler { } } } - diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index 1e59e68..99c1400 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -2,6 +2,8 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log +import com.sameerasw.airsync.domain.model.ConnectedDevice +import com.sameerasw.airsync.domain.model.NetworkDeviceConnection import com.sameerasw.airsync.widget.AirSyncWidgetProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -639,12 +641,34 @@ object WebSocketUtil { cancelAutoReconnect() } + private fun buildLanFirstReconnectCandidates( + last: ConnectedDevice, + targetConnection: NetworkDeviceConnection?, + ourIp: String, + discoveredList: List + ): Pair, String> { + val discoveryMatch = discoveredList.find { it.name == last.name } + val savedCandidates = targetConnection?.getOrderedReconnectCandidates(ourIp).orEmpty() + val lastKnownCandidates = NetworkDeviceConnection.rankIps(last.ipAddress.split(",")) + + val combined = when { + discoveryMatch != null -> { + discoveryMatch.getOrderedIps() + savedCandidates + lastKnownCandidates + } + + else -> savedCandidates + lastKnownCandidates + } + + val source = if (discoveryMatch != null) "discovery+saved" else "saved" + return NetworkDeviceConnection.rankIps(combined) to source + } + /** * Internal logic to attempt auto-reconnection to the last known device. - * Uses discovery-triggered strategy. + * Prefers LAN discovery, then falls back to saved LAN candidates when discovery is filtered. */ private fun tryStartAutoReconnect(context: Context) { - if (autoReconnectActive.get()) return // already running + if (autoReconnectActive.get()) return autoReconnectActive.set(true) autoReconnectStartTime = System.currentTimeMillis() @@ -653,63 +677,73 @@ object WebSocketUtil { try { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) - // Monitor discovered devices - UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> - if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect + while (autoReconnectActive.get()) { + if (isConnected.get() || isConnecting.get()) { + delay(2_000) + continue + } val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() if (manual || !autoEnabled) { cancelAutoReconnect() - return@collect + break } - val last = ds.getLastConnectedDevice().first() ?: return@collect - DeviceInfoUtil.getWifiIpAddress(context) - ?: return@collect - - // 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 } - - if (targetConnection != null) { - 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, - port = port, - symmetricKey = targetConnection.symmetricKey, - manualAttempt = false, - onConnectionStatus = { connected -> - if (connected) { - CoroutineScope(Dispatchers.IO).launch { - try { - ds.updateNetworkDeviceLastConnected( - targetConnection.deviceName, - System.currentTimeMillis() - ) - } catch (_: Exception) { - } - cancelAutoReconnect() - } + val last = ds.getLastConnectedDevice().first() + val ourIp = DeviceInfoUtil.getWifiIpAddress(context) + if (last == null || ourIp.isNullOrBlank()) { + delay(5_000) + continue + } + + val all = ds.getAllNetworkDeviceConnections().first() + val targetConnection = all.firstOrNull { it.deviceName == last.name } + val (candidates, source) = buildLanFirstReconnectCandidates( + last = last, + targetConnection = targetConnection, + ourIp = ourIp, + discoveredList = UDPDiscoveryManager.discoveredDevices.value + ) + + if (candidates.isEmpty()) { + Log.d(TAG, "Auto-reconnect has no LAN candidates for ${last.name}") + delay(8_000) + continue + } + + val port = targetConnection?.port?.toIntOrNull() ?: last.port.toIntOrNull() ?: 6996 + val symmetricKey = targetConnection?.symmetricKey ?: last.symmetricKey + val ips = candidates.joinToString(",") + autoReconnectAttempts += 1 + + Log.d( + TAG, + "LAN-first auto-reconnect attempt #$autoReconnectAttempts via $source to $ips:$port" + ) + + connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = symmetricKey, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + CoroutineScope(Dispatchers.IO).launch { + targetConnection?.let { + ds.updateNetworkDeviceLastConnected( + it.deviceName, + System.currentTimeMillis() + ) } + cancelAutoReconnect() } - ) + } } - } + ) + + delay(8_000) } } catch (e: Exception) { Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") diff --git a/build-enhanced.sh b/build-enhanced.sh new file mode 100755 index 0000000..ddacbd5 --- /dev/null +++ b/build-enhanced.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -euo pipefail + +JAVA_HOME_DEFAULT="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" +export JAVA_HOME="${JAVA_HOME:-$JAVA_HOME_DEFAULT}" +export PATH="${JAVA_HOME}/bin:$PATH" + +BUILD_VARIANT="${BUILD_VARIANT:-debug}" +GRADLE_TASK="assembleDebug" +APK_GLOB="app/build/outputs/apk/debug/*.apk" + +if [ "${BUILD_VARIANT}" = "release" ]; then + GRADLE_TASK="assembleRelease" + APK_GLOB="app/build/outputs/apk/release/*.apk" +fi + +echo "Building AirSync Android (${BUILD_VARIANT})..." +echo "Using JAVA_HOME=${JAVA_HOME}" + +if [ ! -f "app/build.gradle.kts" ]; then + echo "Android project not found" + exit 1 +fi + +./gradlew "${GRADLE_TASK}" + +mkdir -p ./release + +APK_PATH="$(find ${APK_GLOB%/*} -name "$(basename "${APK_GLOB}")" -type f | head -1)" +if [ -z "${APK_PATH}" ]; then + echo "Built APK not found" + exit 1 +fi + +APK_NAME="AirSync-${BUILD_VARIANT}.apk" +cp "${APK_PATH}" "./release/${APK_NAME}" + +echo "Packaged APK: ./release/${APK_NAME}" + +grant_android_permissions() { + local package_name="com.sameerasw.airsync" + local listener_component="${package_name}/${package_name}.service.MediaNotificationListener" + + echo "Granting AirSync permissions via adb..." + + adb shell pm grant "${package_name}" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true + adb shell pm grant "${package_name}" android.permission.READ_CALL_LOG >/dev/null 2>&1 || true + adb shell pm grant "${package_name}" android.permission.READ_CONTACTS >/dev/null 2>&1 || true + adb shell pm grant "${package_name}" android.permission.READ_PHONE_STATE >/dev/null 2>&1 || true + adb shell pm grant "${package_name}" android.permission.CAMERA >/dev/null 2>&1 || true + + adb shell cmd appops set --uid "${package_name}" MANAGE_EXTERNAL_STORAGE allow >/dev/null 2>&1 || true + adb shell cmd deviceidle whitelist +"${package_name}" >/dev/null 2>&1 || true + adb shell cmd notification allow_listener "${listener_component}" >/dev/null 2>&1 || true + + local enabled_listeners + enabled_listeners="$(adb shell settings get secure enabled_notification_listeners 2>/dev/null | tr -d '\r')" + if [[ "${enabled_listeners}" != *"${listener_component}"* ]]; then + if [ -n "${enabled_listeners}" ] && [ "${enabled_listeners}" != "null" ]; then + adb shell settings put secure enabled_notification_listeners "${enabled_listeners}:${listener_component}" >/dev/null 2>&1 || true + else + adb shell settings put secure enabled_notification_listeners "${listener_component}" >/dev/null 2>&1 || true + fi + fi + + echo "ADB permission pass complete." +} + +if adb get-state >/dev/null 2>&1; then + adb install -r "./release/${APK_NAME}" + grant_android_permissions + echo "Installed ./release/${APK_NAME} via adb" +else + echo "No adb device detected. Install manually from ./release/${APK_NAME}" +fi