diff --git a/README.md b/README.md index b99220f5..e9146c46 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ Min : Android 11 [](https://play.google.com/store/apps/details?id=com.sameerasw.airsync) +## Features +- QR Code / Google Lens pairing for quick setup +- Real-time notification mirroring and actionable replies +- Media playback and volume control from Mac +- Cross-device clipboard synchronization +- **Cellular Network Monitoring**: View your Android's cellular status (LTE, 5G, No Signal) directly from your Mac's menu bar. +- **Auto-Start Support**: Enhanced background stability with auto-start helpers for various Android OEMs (Xiaomi, OPPO, Vivo, Letv, etc.). + ## How to connect? Use your built-in camera or Google Lens or anything that can scan a QR code. It will prompt you to open the app. Once authorized, the last device will be saved on the mobile for now for easier reconnection. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dbba43e1..4ed25793 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ android { defaultConfig { applicationId = "com.sameerasw.airsync" minSdk = 30 - versionCode = 29 + versionCode = 30 versionName = "4.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 8e6bf3e9..ea656f2f 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -37,7 +37,7 @@ import com.sameerasw.airsync.utils.KeyguardHelper import com.sameerasw.airsync.utils.NotesRoleManager import com.sameerasw.airsync.utils.PermissionUtil import com.sameerasw.airsync.utils.ShortcutUtil -import com.sameerasw.airsync.utils.UDPDiscoveryManager +import com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator import com.sameerasw.airsync.utils.WebSocketUtil import com.canerture.exceptionreport.handler.ExceptionReport import com.sameerasw.airsync.crash.CrashHandler @@ -583,8 +583,8 @@ class MainActivity : ComponentActivity() { val isDiscoveryEnabled = runBlocking { ds.getDeviceDiscoveryEnabled().first() } - UDPDiscoveryManager.start(this, isDiscoveryEnabled) - UDPDiscoveryManager.burstBroadcast(this) + DiscoveryOrchestrator.start(this, isDiscoveryEnabled) + DiscoveryOrchestrator.burstBroadcast(this) } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt index cc161b3b..f7b3515c 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt @@ -44,21 +44,21 @@ class BleConnectionManager(private val context: Context) { scope.launch { combine( dataStoreManager.getBleSyncEnabled(), - dataStoreManager.getBleAutoConnectEnabled(), + dataStoreManager.getUserManuallyDisconnected(), WebSocketUtil.connectionState - ) { enabled, auto, wsConnected -> - Triple(enabled, auto, wsConnected) - }.collectLatest { (enabled, _, wsConnected) -> + ) { enabled, manuallyDisconnected, wsConnected -> + Triple(enabled, manuallyDisconnected, wsConnected) + }.collectLatest { (enabled, manuallyDisconnected, wsConnected) -> isBleEnabled = enabled - updateBleState(regularConnectionActive = wsConnected) + updateBleState(regularConnectionActive = wsConnected, manuallyDisconnected = manuallyDisconnected) } } } - private fun updateBleState(regularConnectionActive: Boolean) { - if (!isBleEnabled) { - Log.d(TAG, "BLE disabled, stopping server") - bleServer?.stop() + private fun updateBleState(regularConnectionActive: Boolean, manuallyDisconnected: Boolean) { + if (!isBleEnabled || manuallyDisconnected) { + Log.d(TAG, "BLE disabled or user manually disconnected, stopping/pausing server") + bleServer?.pauseAdvertising() return } @@ -93,4 +93,15 @@ class BleConnectionManager(private val context: Context) { fun disconnectAllConnectedDevices() { bleServer?.disconnectAllConnectedDevices() } + + fun restartServer() { + Log.d(TAG, "Restarting BLE GATT server...") + bleServer?.stop() + bleServer = BleGattServer(context) + _serverFlow.value = bleServer + BleTransportBridge.initialize(bleServer!!) + if (isBleEnabled) { + bleServer?.start() + } + } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 37744372..a82ae4d2 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -38,8 +38,8 @@ import java.util.concurrent.ConcurrentLinkedQueue class BleGattServer(private val context: Context) { companion object { private const val TAG = "BleGattServer" - private var instance: BleGattServer? = null - fun isAnyAuthenticated(): Boolean = instance?.isAuthenticated ?: false + private val authenticatedFlag = java.util.concurrent.atomic.AtomicBoolean(false) + fun isAnyAuthenticated(): Boolean = authenticatedFlag.get() } private val bluetoothManager = @@ -52,7 +52,6 @@ class BleGattServer(private val context: Context) { private var isBleSyncEnabled = true init { - instance = this scope.launch { dataStoreManager.getBleSyncEnabled().collect { enabled -> isBleSyncEnabled = enabled @@ -121,6 +120,7 @@ class BleGattServer(private val context: Context) { pendingServices.clear() _connectionState.value = BleConnectionState.DISCONNECTED isAuthenticated = false + authenticatedFlag.set(false) isAdvertisingPaused = false } @@ -293,6 +293,7 @@ class BleGattServer(private val context: Context) { _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false + authenticatedFlag.set(false) if (gattServer != null) { if (isBleSyncEnabled) { if (!isAdvertisingPaused) { @@ -497,12 +498,14 @@ class BleGattServer(private val context: Context) { if (token.contentEquals(expectedToken.toByteArray(Charsets.UTF_8))) { Log.i(TAG, "BLE Auth Success!") isAuthenticated = true + authenticatedFlag.set(true) _connectionState.value = BleConnectionState.AUTHENTICATED sendNotification( BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS) ) BleTransportBridge.sendDeviceName() + dataStoreManager.setUserManuallyDisconnected(false) startHeartbeat() } else { Log.w(TAG, "BLE Auth Failed! Token mismatch.") @@ -642,10 +645,17 @@ class BleGattServer(private val context: Context) { isSending[uuid] = true val characteristic = findCharacteristic(uuid) ?: return - characteristic.value = data - - connectedDevices.forEach { device -> - gattServer?.notifyCharacteristicChanged(device, characteristic, false) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + connectedDevices.forEach { device -> + gattServer?.notifyCharacteristicChanged(device, characteristic, false, data) + } + } else { + @Suppress("DEPRECATION") + characteristic.value = data + connectedDevices.forEach { device -> + @Suppress("DEPRECATION") + gattServer?.notifyCharacteristicChanged(device, characteristic, false) + } } } @@ -698,6 +708,7 @@ class BleGattServer(private val context: Context) { } } isAuthenticated = false + authenticatedFlag.set(false) _connectionState.value = BleConnectionState.DISCONNECTED } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index f46d2b06..c179c969 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -72,6 +72,10 @@ object BleTransportBridge { gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) } + fun sendManualDisconnect() { + gattServer?.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, "remote|manual_disconnect") + } + fun sendDeviceName(context: android.content.Context? = null) { val ctx = context ?: com.sameerasw.airsync.AirSyncApp.getContext() ?: return kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { @@ -186,9 +190,13 @@ object BleTransportBridge { if (parts.size >= 2) { val id = parts[0] val actionName = parts[1] + // Reply text is optional and was previously dropped, so inline replies + // never worked over BLE. Empty means "plain button", not an empty reply. + val replyText = parts.getOrNull(2)?.takeIf { it.isNotEmpty() } com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction( id, - actionName + actionName, + replyText ) } } 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 8fb6fb97..8ee9920c 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 @@ -61,6 +61,9 @@ class DataStoreManager(private val context: Context) { // Send now playing toggle private val SEND_NOW_PLAYING_ENABLED = booleanPreferencesKey("send_now_playing_enabled") + // Excluded media packages preference + private val EXCLUDED_MEDIA_PACKAGES = stringPreferencesKey("excluded_media_packages") + // Keep previous link toggle private val KEEP_PREVIOUS_LINK_ENABLED = booleanPreferencesKey("keep_previous_link_enabled") @@ -250,6 +253,20 @@ class DataStoreManager(private val context: Context) { } } + // Excluded media packages + suspend fun setExcludedMediaPackages(packages: Set) { + context.dataStore.edit { preferences -> + preferences[EXCLUDED_MEDIA_PACKAGES] = packages.joinToString(",") + } + } + + fun getExcludedMediaPackages(): Flow> { + return context.dataStore.data.map { preferences -> + val raw = preferences[EXCLUDED_MEDIA_PACKAGES] ?: "" + if (raw.isBlank()) emptySet() else raw.split(",").toSet() + } + } + // Keep previous link toggle suspend fun setKeepPreviousLinkEnabled(enabled: Boolean) { context.dataStore.edit { preferences -> 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 afb6a3d0..ce4df9e9 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 @@ -167,6 +167,14 @@ class AirSyncRepositoryImpl( return dataStoreManager.getSendNowPlayingEnabled() } + override suspend fun setExcludedMediaPackages(packages: Set) { + dataStoreManager.setExcludedMediaPackages(packages) + } + + override fun getExcludedMediaPackages(): Flow> { + return dataStoreManager.getExcludedMediaPackages() + } + // New: Keep previous link setting override suspend fun setKeepPreviousLinkEnabled(enabled: Boolean) { dataStoreManager.setKeepPreviousLinkEnabled(enabled) 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 52b7d34e..9c40b45d 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 @@ -25,6 +25,7 @@ data class UiState( val manualIsPlus: Boolean = false, val isContinueBrowsingEnabled: Boolean = true, val isSendNowPlayingEnabled: Boolean = true, + val excludedMediaPackages: Set = emptySet(), val isKeepPreviousLinkEnabled: Boolean = true, val isSmartspacerShowWhenDisconnected: Boolean = false, val isMacMediaControlsEnabled: Boolean = true, 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 87f23e9c..9fb01cb0 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 @@ -73,6 +73,9 @@ interface AirSyncRepository { suspend fun setSendNowPlayingEnabled(enabled: Boolean) fun getSendNowPlayingEnabled(): Flow + suspend fun setExcludedMediaPackages(packages: Set) + fun getExcludedMediaPackages(): Flow> + // Keep previous link settings suspend fun setKeepPreviousLinkEnabled(enabled: Boolean) fun getKeepPreviousLinkEnabled(): Flow diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt index b7f2f7c3..75c40592 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt @@ -299,24 +299,7 @@ private fun ClipboardActionScreenContent( ) } - else -> { - // Default/Idle icon - val iconPainter = when (shortcutAction) { - ShortcutUtil.DASH_ACTION_LOCK -> painterResource(id = R.drawable.rounded_lock_24) - ShortcutUtil.DASH_ACTION_DISCONNECT -> painterResource(id = R.drawable.rounded_mimo_disconnect_24) - ShortcutUtil.DASH_ACTION_RECONNECT -> painterResource(id = R.drawable.rounded_devices_24) - ShortcutUtil.DASH_ACTION_REMOTE -> painterResource(id = R.drawable.rounded_compare_arrows_24) - ShortcutUtil.DASH_ACTION_CLIPBOARD -> painterResource(id = R.drawable.ic_clipboard_24) - android.content.Intent.ACTION_SEND -> painterResource(id = R.drawable.rounded_sync_desktop_24) - else -> painterResource(id = R.drawable.ic_clipboard_24) - } - Icon( - painter = iconPainter, - contentDescription = "Sync", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + } } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt index 1f7d8202..280e7c64 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.sameerasw.airsync.presentation.ui.components import android.content.Intent diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt index ec2e1b95..6ed00a72 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.sameerasw.airsync.presentation.ui.components import android.graphics.Bitmap diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt index dea4080e..6b0147e5 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt @@ -49,7 +49,7 @@ fun HelpAndGuidesContent() { HelpSection( title = "Permissions & Usage", iconRes = R.drawable.rounded_security_24, - content = "• Notification Access: Required to sync alerts/media. For sideloaded installs, enable 'Restricted Settings' in App Info.\n• Post Notifications: For the ongoing connection indicator.\n• Background Usage: Keeps the connection alive.\n• Storage: Required for wallpaper sync (still images only).", + content = "• Notification Access: Required to sync alerts/media. For sideloaded installs, enable 'Restricted Settings' in App Info.\n• Post Notifications: For the ongoing connection indicator.\n• Background Usage: Keeps the connection alive.\n• Storage: Required for wallpaper sync.\n• Location & Phone State: Required to sync cellular network status to your Mac.", links = listOf("Privacy Policy" to "https://www.sameerasw.com/airsync/privacy") ), HelpSection( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/KeyboardInputSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/KeyboardInputSheet.kt index ff5a11dc..5dc9dd48 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/KeyboardInputSheet.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/KeyboardInputSheet.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.sameerasw.airsync.presentation.ui.components import android.view.HapticFeedbackConstants diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt index 97c051f6..88492f27 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.core.net.toUri import androidx.lifecycle.Lifecycle 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 96e51742..64835854 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 @@ -108,6 +108,7 @@ fun SettingsView( ) { val haptics = LocalHapticFeedback.current var showAppSelectionSheet by remember { mutableStateOf(false) } + var showMediaAppSelectionSheet by remember { mutableStateOf(false) } val density = androidx.compose.ui.platform.LocalDensity.current val minHeaderHeight = 200.dp @@ -381,7 +382,11 @@ fun SettingsView( onClearIconSyncMessage = { viewModel.clearIconSyncMessage() }, - isConnected = uiState.isConnected + isConnected = uiState.isConnected, + onRestartBleServer = { + com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.restartServer() + Toast.makeText(context, "BLE GATT Server restarted", Toast.LENGTH_SHORT).show() + } ) } } @@ -530,6 +535,11 @@ fun SettingsView( isMacMediaControlsEnabled = uiState.isMacMediaControlsEnabled, onToggleMacMediaControls = { enabled -> viewModel.setMacMediaControlsEnabled(enabled) + }, + onOpenExcludedApps = { + HapticUtil.performClick(haptics) + viewModel.loadMediaApps(context) + showMediaAppSelectionSheet = true } ) @@ -634,6 +644,21 @@ fun SettingsView( isLoading = apps.isEmpty() ) } + + if (showMediaAppSelectionSheet) { + val mediaApps by viewModel.mediaApps.collectAsState() + AppSelectionSheet( + onDismissRequest = { showMediaAppSelectionSheet = false }, + apps = mediaApps, + onAppToggle = { pkg, isExcluded -> + viewModel.toggleExcludedMediaPackage(pkg, isExcluded) + }, + onSaveAll = { updatedList -> + viewModel.saveAllMediaApps(updatedList) + }, + isLoading = mediaApps.isEmpty() + ) + } } @Composable diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt index 1a845e02..0e54642e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt @@ -44,6 +44,7 @@ fun DeveloperModeCard( onManualSyncIcons: () -> Unit, onClearIconSyncMessage: () -> Unit, isConnected: Boolean, + onRestartBleServer: (() -> Unit)? = null, modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current @@ -144,6 +145,18 @@ fun DeveloperModeCard( Text("Reset Onboarding") } + onRestartBleServer?.let { restartBle -> + Button( + onClick = { + HapticUtil.performClick(haptics) + restartBle() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Restart BLE Server") + } + } + Spacer(modifier = Modifier.height(8.dp)) Text( "Icons", diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt index a5c42da3..a6d96807 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt @@ -13,8 +13,10 @@ fun MediaSyncCard( onToggleSendNowPlaying: (Boolean) -> Unit, isMacMediaControlsEnabled: Boolean, onToggleMacMediaControls: (Boolean) -> Unit, + onOpenExcludedApps: () -> Unit = {}, modifier: Modifier = Modifier ) { + val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { IconToggleItem( iconRes = R.drawable.rounded_music_cast_24, @@ -23,6 +25,18 @@ fun MediaSyncCard( isChecked = isSendNowPlayingEnabled, onCheckedChange = onToggleSendNowPlaying ) + if (isSendNowPlayingEnabled) { + IconToggleItem( + iconRes = R.drawable.rounded_notification_settings_24, + title = androidx.compose.ui.res.stringResource(R.string.action_exclude_media_apps), + description = androidx.compose.ui.res.stringResource(R.string.subtitle_exclude_media_apps), + showToggle = false, + onClick = { + com.sameerasw.airsync.utils.HapticUtil.performClick(haptics) + onOpenExcludedApps() + } + ) + } IconToggleItem( iconRes = R.drawable.rounded_smart_display_24, title = "Show Mac Media Controls", diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt index c158c64d..cf5d64e9 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.sameerasw.airsync.presentation.ui.screens import android.content.ClipDescription 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 bf498633..97f4af08 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 @@ -241,6 +241,21 @@ class AirSyncViewModel( } } + // Observe Send now playing preference + viewModelScope.launch { + repository.getSendNowPlayingEnabled().collect { enabled -> + _uiState.value = _uiState.value.copy(isSendNowPlayingEnabled = enabled) + } + } + + // Observe Excluded media packages preference + viewModelScope.launch { + repository.getExcludedMediaPackages().collect { packages -> + _uiState.value = _uiState.value.copy(excludedMediaPackages = packages) + com.sameerasw.airsync.service.MediaNotificationListener.setExcludedMediaPackages(packages) + } + } + // Observe Notify on Crash preference viewModelScope.launch { repository.getNotifyOnCrashEnabled().collect { enabled -> @@ -1198,6 +1213,57 @@ class AirSyncViewModel( } } + private val _mediaApps = + MutableStateFlow>(emptyList()) + val mediaApps: StateFlow> = + _mediaApps.asStateFlow() + + fun loadMediaApps(context: Context) { + viewModelScope.launch(Dispatchers.IO) { + try { + val installed = com.sameerasw.airsync.utils.AppUtil.getInstalledApps(context) + val excluded = _uiState.value.excludedMediaPackages + val mapped = installed.map { app -> + app.copy(isEnabled = excluded.contains(app.packageName)) + } + _mediaApps.value = mapped + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to load media apps: ${e.message}") + } + } + } + + fun toggleExcludedMediaPackage(packageName: String, isExcluded: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + try { + val currentExcluded = _uiState.value.excludedMediaPackages.toMutableSet() + if (isExcluded) { + currentExcluded.add(packageName) + } else { + currentExcluded.remove(packageName) + } + repository.setExcludedMediaPackages(currentExcluded) + _mediaApps.value = _mediaApps.value.map { app -> + if (app.packageName == packageName) app.copy(isEnabled = isExcluded) else app + } + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to toggle excluded media package: ${e.message}") + } + } + } + + fun saveAllMediaApps(apps: List) { + viewModelScope.launch(Dispatchers.IO) { + try { + val excluded = apps.filter { it.isEnabled }.map { it.packageName }.toSet() + repository.setExcludedMediaPackages(excluded) + _mediaApps.value = apps + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to save all media apps: ${e.message}") + } + } + } + private val _notificationApps = MutableStateFlow>(emptyList()) val notificationApps: StateFlow> = diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt index 68e0c8ef..d7d2352c 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt @@ -88,10 +88,10 @@ class InboundQuickShareConnection( val firstFrame = readFrame() Log.d(TAG, "Read first frame: ${firstFrame.size} bytes") val offlineFrame = OfflineFrame.ADAPTER.decode(firstFrame) - if (offlineFrame.v1!!.type != V1Frame.FrameType.CONNECTION_REQUEST) { + if (offlineFrame.v1?.type != V1Frame.FrameType.CONNECTION_REQUEST) { throw IllegalStateException("Expected CONNECTION_REQUEST, got ${offlineFrame.v1!!.type}") } - val connectionRequest = offlineFrame.v1!!.connection_request + val connectionRequest = offlineFrame.v1.connection_request endpointName = connectionRequest!!.endpoint_name Log.d(TAG, "Received connection request from $endpointName") @@ -144,7 +144,7 @@ class InboundQuickShareConnection( val responseFrameData = readFrame() Log.d(TAG, "Read ConnectionResponse: ${responseFrameData.size} bytes") val responseFrame = OfflineFrame.ADAPTER.decode(responseFrameData) - if (responseFrame.v1!!.type != V1Frame.FrameType.CONNECTION_RESPONSE) { + if (responseFrame.v1?.type != V1Frame.FrameType.CONNECTION_RESPONSE) { throw IllegalStateException("Expected CONNECTION_RESPONSE, got ${responseFrame.v1!!.type}") } @@ -284,7 +284,7 @@ class InboundQuickShareConnection( when (offlineFrame.v1?.type) { V1Frame.FrameType.PAYLOAD_TRANSFER -> { - val transfer = offlineFrame.v1!!.payload_transfer!! + val transfer = offlineFrame.v1.payload_transfer!! val header = transfer.payload_header val chunk = transfer.payload_chunk @@ -333,10 +333,11 @@ class InboundQuickShareConnection( val v1Frame = frame.v1 ?: return when (v1Frame.type) { SharingV1.FrameType.INTRODUCTION -> { - introduction = v1Frame.introduction - Log.d(TAG, "Received introduction: ${introduction?.file_metadata?.size} files") - prepareFiles(v1Frame.introduction!!) - onIntroductionReceived?.invoke(v1Frame.introduction!!) + val intro = v1Frame.introduction ?: return + introduction = intro + Log.d(TAG, "Received introduction: ${intro.file_metadata.size} files") + prepareFiles(intro) + onIntroductionReceived?.invoke(intro) } SharingV1.FrameType.CANCEL -> { diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt index 551047fd..94b78445 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt @@ -70,22 +70,22 @@ open class QuickShareConnection( // 1. Verify HMAC val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(context.receiveHmacKey, "HmacSHA256")) - val hbBytes = smsg.header_and_body!!.toByteArray() + val hbBytes = smsg.header_and_body.toByteArray() val calculatedHmac = mac.doFinal(hbBytes) - if (!calculatedHmac.contentEquals(smsg.signature!!.toByteArray())) { + if (!calculatedHmac.contentEquals(smsg.signature.toByteArray())) { throw SecurityException("SecureMessage HMAC mismatch") } // 2. Decrypt HeaderAndBody - val hb = HeaderAndBody.ADAPTER.decode(smsg.header_and_body!!) - val iv = hb.header_!!.iv!!.toByteArray() + val hb = HeaderAndBody.ADAPTER.decode(smsg.header_and_body) + val iv = hb.header_.iv!!.toByteArray() val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init( Cipher.DECRYPT_MODE, SecretKeySpec(context.decryptKey, "AES"), IvParameterSpec(iv) ) - val decryptedData = cipher.doFinal(hb.body!!.toByteArray()) + val decryptedData = cipher.doFinal(hb.body.toByteArray()) // 3. Parse DeviceToDeviceMessage val d2dMsg = DeviceToDeviceMessage.ADAPTER.decode(decryptedData) diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt index 36870cac..2629c86e 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt @@ -237,8 +237,15 @@ class QuickShareService : Service() { } else -> { - // Remove the startForeground/createNotification call from here - server.start() + serviceScope.launch { + val enabled = dataStoreManager.isQuickShareEnabled().first() + if (enabled) { + server.start() + } else { + Log.d(TAG, "Service restarted by system but Quick Share is disabled, stopping") + stopDiscovery() + } + } } } return START_STICKY 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 f8fa40e1..c2830de7 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -8,6 +8,7 @@ import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log import com.sameerasw.airsync.MainActivity +import com.sameerasw.airsync.data.ble.BleGattServer import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.utils.MacDeviceStatusManager import com.sameerasw.airsync.utils.WebSocketUtil @@ -86,7 +87,9 @@ class AirSyncTileService : TileService() { super.onClick() serviceScope.launch { - val isConnected = WebSocketUtil.isConnected() + val isWsConnected = WebSocketUtil.isConnected() + val isBleConnected = BleGattServer.isAnyAuthenticated() + val isConnected = isWsConnected || isBleConnected val isAuto = WebSocketUtil.isAutoReconnecting() if (isAuto && !isConnected) { 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 0e8eca0d..668bc5e9 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -46,6 +46,15 @@ class MediaNotificationListener : NotificationListenerService() { @Volatile private var isNowPlayingEnabled: Boolean = true + // Excluded media packages + @Volatile + private var excludedMediaPackages: Set = emptySet() + + fun setExcludedMediaPackages(packages: Set) { + excludedMediaPackages = packages + Log.d(TAG, "Updated excluded media packages: ${packages.size} apps excluded") + } + fun setNowPlayingEnabled(context: Context, enabled: Boolean) { isNowPlayingEnabled = enabled if (!enabled) { @@ -119,8 +128,7 @@ class MediaNotificationListener : NotificationListenerService() { if (activeSessions.isNotEmpty()) { for (controller in activeSessions) { try { - if (controller.packageName == context.packageName) { - // Log.d(TAG, "Skipping own media session from package: ${controller.packageName}") + if (controller.packageName == context.packageName || excludedMediaPackages.contains(controller.packageName)) { continue } } catch (_: Exception) { @@ -454,6 +462,33 @@ class MediaNotificationListener : NotificationListenerService() { } catch (e: Exception) { Log.e(TAG, "Failed to start AirSyncService from listener", e) } + + // Re-register notifications that were already in the shade before this + // process started. Without this, actions and dismissals coming from the + // client fail with "not found" for every notification that predates the + // listener connecting. The persisted key->id mapping recovers the exact + // ID the client already holds even when the notification was updated + // since (its postTime — embedded in generated IDs — changes on update, + // while sbn.key stays stable). + try { + val currentKeys = mutableSetOf() + activeNotifications?.forEach { sbn -> + currentKeys.add(sbn.key) + val title = sbn.notification?.extras?.getString(Notification.EXTRA_TITLE) ?: "" + val notificationId = NotificationDismissalUtil.getIdBySystemKey(sbn.key) + ?: NotificationDismissalUtil.getPersistedIdBySystemKey(sbn.key) + ?: NotificationDismissalUtil.generateNotificationId( + sbn.packageName, + title, + sbn.postTime + ) + NotificationDismissalUtil.storeNotification(notificationId, sbn) + } + NotificationDismissalUtil.prunePersistedMappings(currentKeys) + } catch (e: Exception) { + Log.e(TAG, "Failed to restore active notifications on listener connect", e) + } + updateMediaInfo() } @@ -656,8 +691,10 @@ class MediaNotificationListener : NotificationListenerService() { return@launch } - // Retrieve existing notification ID or generate a new one + // Retrieve existing notification ID (in-memory, then persisted + // from a previous process) or generate a new one val notificationId = NotificationDismissalUtil.getIdBySystemKey(sbn.key) + ?: NotificationDismissalUtil.getPersistedIdBySystemKey(sbn.key) ?: NotificationDismissalUtil.generateNotificationId( sbn.packageName, title, diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt b/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt index e428bba7..645515bb 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.sameerasw.airsync.utils import android.content.Context diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt index 23603636..97bf6ebd 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt @@ -30,7 +30,13 @@ object CallControlUtil { context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager if (telecomManager != null) { Log.d(TAG, "Accepting ringing call via TelecomManager") - telecomManager.acceptRingingCall() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + @Suppress("DEPRECATION") + telecomManager.acceptRingingCall(android.telecom.VideoProfile.STATE_AUDIO_ONLY) + } else { + @Suppress("DEPRECATION") + telecomManager.acceptRingingCall() + } return } } catch (e: Exception) { @@ -65,6 +71,7 @@ object CallControlUtil { context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager if (telecomManager != null) { Log.d(TAG, "Ending/declining call via TelecomManager") + @Suppress("DEPRECATION") val success = telecomManager.endCall() Log.d(TAG, "TelecomManager.endCall returned: $success") if (success) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt b/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt index 327d5039..2255f19d 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt @@ -63,17 +63,24 @@ class ContactLookupHelper(private val context: Context) { } /** - * Get device's country code from locale + * Get device's country code from SIM, cellular network ISO, or locale. */ private fun getDeviceCountryCode(): String { return try { + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? android.telephony.TelephonyManager + val simCountry = tm?.simCountryIso?.uppercase()?.takeIf { it.isNotEmpty() } + if (simCountry != null) return simCountry + + val networkCountry = tm?.networkCountryIso?.uppercase()?.takeIf { it.isNotEmpty() } + if (networkCountry != null) return networkCountry + val locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { context.resources.configuration.locales.get(0) } else { @Suppress("DEPRECATION") context.resources.configuration.locale } - locale?.country?.takeIf { it.isNotEmpty() } ?: "US" + locale?.country?.uppercase()?.takeIf { it.isNotEmpty() } ?: "US" } catch (e: Exception) { "US" // Fallback } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt index 0329017a..0ca66278 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt @@ -11,6 +11,11 @@ import java.util.concurrent.ConcurrentHashMap object NotificationDismissalUtil { private const val TAG = "NotificationDismissalUtil" + // Persisted sbn.key -> generated ID mappings. IDs embed postTime, which + // changes when a notification is updated, while sbn.key stays stable — + // so this is what keeps client-held IDs valid across process restarts. + private const val ID_MAP_PREFS = "notification_id_mappings" + // Store active notifications with their IDs for dismissal or actions private val activeNotifications = ConcurrentHashMap() @@ -38,6 +43,7 @@ object NotificationDismissalUtil { // Keep reverse lookup so we can map sbn.key -> id on removal try { keyToId[notification.key] = id + idMapPrefs()?.edit()?.putString(notification.key, id)?.apply() } catch (_: Exception) { } Log.d(TAG, "Stored notification with ID: $id") @@ -48,6 +54,7 @@ object NotificationDismissalUtil { oldestKeys.forEach { oldId -> activeNotifications.remove(oldId)?.let { sbn -> keyToId.remove(sbn.key) + idMapPrefs()?.edit()?.remove(sbn.key)?.apply() } } } @@ -76,6 +83,7 @@ object NotificationDismissalUtil { // Cleanup maps after cancel is requested (onNotificationRemoved may also do this) activeNotifications.remove(notificationId) keyToId.remove(notification.key) + idMapPrefs()?.edit()?.remove(notification.key)?.apply() Log.d(TAG, "Successfully dismissed notification: $notificationId") true } else { @@ -132,7 +140,7 @@ object NotificationDismissalUtil { } val pendingIntent = target.actionIntent - if (replyText != null) { + if (!replyText.isNullOrEmpty()) { // Inline reply path val remoteInputs = target.remoteInputs if (remoteInputs.isNullOrEmpty()) { @@ -180,6 +188,33 @@ object NotificationDismissalUtil { null } + /** + * Lookup a generated ID persisted from a previous process, by system key. + */ + fun getPersistedIdBySystemKey(systemKey: String): String? = try { + idMapPrefs()?.getString(systemKey, null) + } catch (_: Exception) { + null + } + + /** + * Drop persisted mappings whose notifications are no longer active. + * Called after re-registering on listener connect. + */ + fun prunePersistedMappings(activeKeys: Set) { + try { + val prefs = idMapPrefs() ?: return + val editor = prefs.edit() + prefs.all.keys.filter { it !in activeKeys }.forEach { editor.remove(it) } + editor.apply() + } catch (_: Exception) { + } + } + + private fun idMapPrefs(): android.content.SharedPreferences? = + getNotificationListenerService()?.applicationContext + ?.getSharedPreferences(ID_MAP_PREFS, android.content.Context.MODE_PRIVATE) + /** * Lookup generated ID by StatusBarNotification */ @@ -202,6 +237,7 @@ object NotificationDismissalUtil { fun removeFromCaches(id: String) { activeNotifications.remove(id)?.let { sbn -> keyToId.remove(sbn.key) + idMapPrefs()?.edit()?.remove(sbn.key)?.apply() } testNotificationIds.remove(id) } 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 c6dee5de..57b6dc97 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -393,7 +393,9 @@ object WebSocketMessageHandler { // We accept either "name" or legacy "action" for action name val actionName = data.optString("name", data.optString("action", "")).ifEmpty { "" } - val replyText = data.optString("text") + // Absent "text" must stay null: an empty string would be taken as an + // inline reply and plain action buttons would never be invoked. + val replyText = data.optString("text").takeIf { it.isNotEmpty() } if (actionName.isEmpty()) { sendNotificationActionResponse( @@ -411,7 +413,7 @@ object WebSocketMessageHandler { replyText ) val message = if (success) { - if (replyText.isNotEmpty()) "Reply sent" else "Action invoked" + if (!replyText.isNullOrEmpty()) "Reply sent" else "Action invoked" } else { "Failed to perform action or notification not found" } @@ -428,10 +430,6 @@ object WebSocketMessageHandler { // Reply immediately with lightweight pong message to keep session active val pongJson = "{\"type\":\"pong\",\"data\":{}}" WebSocketUtil.sendMessage(pongJson) - - // Respond to ping with current device status to keep connection alive - // We must force sync here because the server expects a response to every ping - SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) } 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 457ebc3c..57b95343 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -85,7 +85,7 @@ object WebSocketUtil { .connectTimeout(5, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // Keep connection alive - .pingInterval(20, TimeUnit.SECONDS) + .pingInterval(5, TimeUnit.SECONDS) .build() } @@ -750,6 +750,17 @@ object WebSocketUtil { } } + // Disconnect any active BLE transport connections + try { + val bleManager = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() + if (bleManager != null && bleManager.isAuthenticated) { + BleTransportBridge.sendManualDisconnect() + } + bleManager?.disconnectAllConnectedDevices() + } catch (e: Exception) { + Log.w(TAG, "Error disconnecting BLE devices: ${e.message}") + } + // Update widgets to reflect new state ctx?.let { c -> try { @@ -894,11 +905,11 @@ object WebSocketUtil { autoReconnectJob = CoroutineScope(Dispatchers.IO).launch { try { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) - acquireWifiLock(context) // 1. Retry Loop (Try last known IPs immediately and periodically) launch { var backoffMs = 2000L + var failedStreak = 0 while (autoReconnectActive.get() && !isConnected()) { val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() @@ -912,7 +923,18 @@ object WebSocketUtil { break } - if (!isConnecting.get()) { + // Check network capabilities before attempting Wi-Fi socket reconnect + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager + val activeNetwork = cm?.activeNetwork + val caps = cm?.getNetworkCapabilities(activeNetwork) + val hasWifiOrVpn = caps?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) == true || + caps?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) == true || + caps?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) == true + + val expandNetworking = ds.getExpandNetworkingEnabled().first() + val canAttemptWifiSocket = hasWifiOrVpn || expandNetworking + + if (!isConnecting.get() && canAttemptWifiSocket) { val last = ds.getLastConnectedDevice().first() if (last != null) { val all = ds.getAllNetworkDeviceConnections().first() @@ -924,6 +946,7 @@ object WebSocketUtil { targetConnection.networkConnections.values.joinToString(",") val port = targetConnection.port.toIntOrNull() ?: 6996 + acquireWifiLock(context) Log.d( TAG, "Proactive retry to $ips:$port (backoff: ${backoffMs}ms)" @@ -937,16 +960,22 @@ object WebSocketUtil { onConnectionStatus = { connected -> if (connected) { cancelAutoReconnect() + } else { + releaseWifiLock() } } ) } } + } else { + releaseWifiLock() } delay(backoffMs) - // Exponential backoff capped at 10 seconds - backoffMs = (backoffMs * 1.5).toLong().coerceAtMost(10_000L) + failedStreak++ + // Exponential backoff capped at 30 seconds after extended failures + val maxCap = if (failedStreak > 10) 30_000L else 10_000L + backoffMs = (backoffMs * 1.5).toLong().coerceAtMost(maxCap) } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/discovery/MdnsDiscoveryBackend.kt b/app/src/main/java/com/sameerasw/airsync/utils/discovery/MdnsDiscoveryBackend.kt index 527c53db..ce2b67fd 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/discovery/MdnsDiscoveryBackend.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/discovery/MdnsDiscoveryBackend.kt @@ -1,3 +1,4 @@ +@file:Suppress("DEPRECATION") package com.sameerasw.airsync.utils.discovery import android.content.Context diff --git a/app/src/main/res/drawable/macpro_gen3.xml b/app/src/main/res/drawable/macpro_gen3.xml index 6370d36d..efec7a1e 100644 --- a/app/src/main/res/drawable/macpro_gen3.xml +++ b/app/src/main/res/drawable/macpro_gen3.xml @@ -1,36 +1,14 @@ - - - - - - - - - + + + + + + + + + - + - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46ca5e24..3f3f1da4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,8 @@ Grant Answer Calls Access Select apps To be notified + Exclude media apps + Apps excluded from now playing sync Notify on crash