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