From 137bfd08bcf02ea3724920bb2aa02227793c8247 Mon Sep 17 00:00:00 2001 From: DinanathDash Date: Tue, 30 Jun 2026 12:05:50 +0530 Subject: [PATCH 01/11] fix: misc UI adjustments and bugfixes --- CONTRIBUTING.md | 2 +- README.md | 12 ++++- .../com/sameerasw/airsync/MainActivity.kt | 14 +++--- .../airsync/data/ble/BleGattServer.kt | 15 +++++-- .../ui/activities/ClipboardActionActivity.kt | 19 +------- .../ui/components/AboutSection.kt | 1 + .../ui/components/FloatingMediaPlayer.kt | 1 + .../ui/components/HelpAndGuides.kt | 2 +- .../ui/components/KeyboardInputSheet.kt | 1 + .../ui/components/RotatingAppIcon.kt | 2 +- .../ui/screens/AirSyncMainScreen.kt | 2 +- .../ui/screens/ClipboardScreen.kt | 1 + .../quickshare/InboundQuickShareConnection.kt | 17 +++---- .../quickshare/QuickShareConnection.kt | 10 ++--- .../airsync/quickshare/QuickShareService.kt | 11 ++++- .../airsync/utils/AdbMdnsDiscovery.kt | 1 + .../airsync/utils/CallControlUtil.kt | 9 +++- .../com/sameerasw/airsync/utils/JsonUtil.kt | 10 ++--- .../utils/NotificationDismissalUtil.kt | 2 +- .../sameerasw/airsync/utils/SyncManager.kt | 4 +- .../airsync/utils/UDPDiscoveryManager.kt | 4 +- .../sameerasw/airsync/utils/WallpaperUtil.kt | 2 +- .../airsync/utils/WebSocketMessageHandler.kt | 4 +- .../utils/discovery/MdnsDiscoveryBackend.kt | 1 + app/src/main/res/drawable/macpro_gen3.xml | 44 +++++-------------- 25 files changed, 92 insertions(+), 99 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1011287..b04602c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to AirSync Android (2.0) +# Contributing to AirSync Android (3.0) Thank you for your interest in contributing! Before you submit a pull request, please read the following guidelines. diff --git a/README.md b/README.md index 15a30b1f..e9146c46 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,21 @@ # airsync-android -Android app for AirSync 2.0 built with Kotlin Jetpack Compose +Android app for AirSync built with Kotlin Jetpack Compose 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 lense or anything that can scan a QR code. I twill prompt you to open the app. Once authorized, The last device will be saved on the mobile for now for easier re-connection. +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. ## [Read Documentation and How-To](https://airsync.notion.site/) diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 207f5738..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 @@ -173,7 +173,7 @@ class MainActivity : ComponentActivity() { // Install and configure the splash screen before any UI rendering val splashScreen = installSplashScreen() - // Make activity draw behind system bars - let the theme handles the colors + // Make activity draw behind system bars - let the theme handle the colors androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) @@ -218,7 +218,7 @@ class MainActivity : ComponentActivity() { if (splashIcon is ImageView && deviceIconRes != null) { // Fade out the original app icon val fadeOutIcon = ObjectAnimator.ofFloat(splashIcon, "alpha", 1f, 0f).apply { - duration = 150 // 0.5 seconds + duration = 150 // 0.15 seconds } fadeOutIcon.doOnEnd { @@ -237,7 +237,7 @@ class MainActivity : ComponentActivity() { // Fade in the new device icon val fadeInIcon = ObjectAnimator.ofFloat(splashIcon, "alpha", 0f, 1f).apply { - duration = 350 // 0.5 seconds + duration = 350 // 0.35 seconds } fadeInIcon.doOnEnd { @@ -249,7 +249,7 @@ class MainActivity : ComponentActivity() { splashIcon, splashScreenViewProvider ) - }, 250) // 0.5 second hold + }, 250) // 0.25 second hold } catch (e: Exception) { Log.e( "MainActivity", @@ -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/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 5d99ed16..65c7ab8d 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 @@ -670,10 +670,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) + } } } 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/screens/AirSyncMainScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt index 96c70b2a..c47121d6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt @@ -152,7 +152,7 @@ fun AirSyncMainScreen( .getPackageInfo(context.packageName, 0) .versionName } catch (_: Exception) { - "2.0.0" + "3.0.0" } val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current 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/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 1e49c991..8a299c7f 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt @@ -228,8 +228,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/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/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index 0709674e..fc330a7c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -26,9 +26,6 @@ object JsonUtil { return """{"type":"device","data":{"name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[]$targetIpJson}}""" } - /** - * Creates a single-line JSON string for device info with ADB ports - */ /** * Creates a single-line JSON string for device info with ADB ports */ @@ -47,9 +44,6 @@ object JsonUtil { return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$targetIpJson}}""" } - /** - * Creates a single-line JSON string for device info with wallpaper - */ /** * Creates a single-line JSON string for device info with wallpaper */ @@ -174,7 +168,9 @@ object JsonUtil { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" + val cellularNetwork = com.sameerasw.airsync.utils.CellularMonitor.currentNetworkState + val cellularNetworkJson = if (cellularNetwork != null) ",\"cellularNetwork\":\"$cellularNetwork\"" else "" + return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired$cellularNetworkJson,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" } /** 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 1a814d17..0329017a 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt @@ -198,7 +198,7 @@ object NotificationDismissalUtil { return suppressedIds.remove(notificationId) } - /** Remove from caches when we it's gone. */ + /** Remove from caches when it's gone. */ fun removeFromCaches(id: String) { activeNotifications.remove(id)?.let { sbn -> keyToId.remove(sbn.key) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 5c02e201..002ec27e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -178,7 +178,7 @@ object SyncManager { val localIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "Unknown" val port = dataStoreManager.getPort().first().toIntOrNull() ?: 6996 val version = context.packageManager - .getPackageInfo(context.packageName, 0).versionName ?: "2.0.0" + .getPackageInfo(context.packageName, 0).versionName ?: "3.0.0" // Get discovered ADB ports from the running mDNS discovery val adbPorts = try { @@ -269,7 +269,7 @@ object SyncManager { val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) if (WebSocketUtil.sendMessage(statusJson)) { Log.d(TAG, "Device status sent") - // Update cache + // Update cache lastAudioInfo = DeviceInfoUtil.getAudioInfo(context, includeNowPlaying) lastBatteryInfo = DeviceInfoUtil.getBatteryInfo(context) } else { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt index eeee99a5..e6af928f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -28,10 +28,10 @@ data class DiscoveredDevice( val lastSeen: Long = System.currentTimeMillis(), val discoverySource: DiscoverySource = DiscoverySource.UDP ) { - //check if it has a local IP (non-Tailscale) + // check if it has a local IP (non-Tailscale) fun hasLocalIp(): Boolean = ips.any { !it.startsWith("100.") } - //check if it has a Tailscale IP + // check if it has a Tailscale IP fun hasTailscaleIp(): Boolean = ips.any { it.startsWith("100.") } // Best IP for connection diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt index de098607..4390336c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WallpaperUtil.kt @@ -26,7 +26,7 @@ object WallpaperUtil { return try { val wallpaperManager = WallpaperManager.getInstance(context) - // Check permissions + // Check permissions if (!hasWallpaperPermissions(context)) { Log.w(TAG, "Missing wallpaper permissions") return null 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 07b644a8..8f1b5978 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -359,7 +359,7 @@ object WebSocketMessageHandler { val success = NotificationDismissalUtil.dismissNotification(notificationId) val message = - if (success) "Notification dismissed" else "Failed to dismiss notification or notification not found" + if (success) "Notification dismissed" else "Failed to dismiss notification or not found" sendNotificationDismissalResponse(notificationId, success, message) } catch (e: Exception) { @@ -560,7 +560,7 @@ object WebSocketMessageHandler { val macName = data.optString("name", "") val isPlus = data.optBoolean("isPlusSubscription", false) - val macVersion = data.optString("version", "2.0.0") + val macVersion = data.optString("version", "3.0.0") Log.d( TAG, 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 From a891e6c336d489a4249c40bcc73fea54ba699d43 Mon Sep 17 00:00:00 2001 From: DinanathDash Date: Mon, 13 Jul 2026 10:53:33 +0530 Subject: [PATCH 02/11] fix: remove unresolved CellularMonitor reference to fix compilation --- app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index fc330a7c..fca3916b 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -168,9 +168,7 @@ object JsonUtil { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" - val cellularNetwork = com.sameerasw.airsync.utils.CellularMonitor.currentNetworkState - val cellularNetworkJson = if (cellularNetwork != null) ",\"cellularNetwork\":\"$cellularNetwork\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired$cellularNetworkJson,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" + return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" } /** From 84e2683eeb6aeed878166f201962cebc3a5c7584 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 23 Jul 2026 13:35:37 +0530 Subject: [PATCH 03/11] feat: replace singleton instance with AtomicBoolean for thread-safe authentication state tracking --- .../java/com/sameerasw/airsync/data/ble/BleGattServer.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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..5299a8bd 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,6 +498,7 @@ 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, @@ -698,6 +700,7 @@ class BleGattServer(private val context: Context) { } } isAuthenticated = false + authenticatedFlag.set(false) _connectionState.value = BleConnectionState.DISCONNECTED } } From 0f32e7718bbc6187f34696fff0519666f700c461 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 23 Jul 2026 13:35:43 +0530 Subject: [PATCH 04/11] chore: increment version code to 30 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From a5844914e92b0c692ef584a64744f3e108cd413e Mon Sep 17 00:00:00 2001 From: Renato Araujo Carneiro Date: Fri, 31 Jul 2026 11:06:13 -0300 Subject: [PATCH 05/11] fix(notifications): make action buttons work and keep IDs stable across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #136 and #137. Plain action buttons (#136) WebSocketMessageHandler read the optional "text" field with optString(), which returns "" when the field is absent. performNotificationAction() branched on `replyText != null`, so an absent text was still treated as an inline reply: every plain action button went down the reply path, failed the remoteInputs check and returned false. Any client that omits "text" for non-reply actions could never invoke a button. The guard now tests for a non-empty string, and the handler normalizes an absent field to null. BleTransportBridge also dropped the optional reply text entirely, so inline replies never worked over BLE. It now forwards it, treating empty as "no reply" for consistency with the WebSocket path. Actions after a process restart (#137) activeNotifications was only populated from onNotificationPosted, so after a process restart nothing already in the shade was actionable — clients got "not found" for notifications that were still visible. onListenerConnected() now re-registers what is currently posted. Re-registering alone is not enough: generated IDs embed postTime, and during a session the first-seen ID is preserved across updates via keyToId. Once that in-memory map is gone, regenerating from the current postTime produces a different ID than the one the client holds, so its requests would still miss. The key -> id mapping is therefore persisted (sbn.key is stable across updates and restarts) and consulted when re-registering, so IDs a client obtained before the restart keep resolving. Stale entries are pruned against the currently active notifications on each listener connect. Verified on a Galaxy Z Fold7 (Android 16) against the macOS client: invoking a plain button now reaches pendingIntent.send() and the app-side effect happens, and an ID held by the client before a force-stop is restored identically afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- .../airsync/data/ble/BleTransportBridge.kt | 6 ++- .../service/MediaNotificationListener.kt | 31 ++++++++++++++- .../utils/NotificationDismissalUtil.kt | 38 ++++++++++++++++++- .../airsync/utils/WebSocketMessageHandler.kt | 6 ++- 4 files changed, 76 insertions(+), 5 deletions(-) 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..781354e2 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 @@ -186,9 +186,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/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 0e8eca0d..1420e663 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -454,6 +454,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 +683,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/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..c7da09a7 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" } From 3a2175d8ed75bbe6f42cbe28118daaf537c9dc13 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 13 Aug 2026 20:34:25 +0530 Subject: [PATCH 06/11] refactor: remove forced status sync from WebSocket ping handler --- .../com/sameerasw/airsync/utils/WebSocketMessageHandler.kt | 4 ---- .../main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index 3f9e439d..331c1ea7 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -428,10 +428,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..aa208b0f 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() } From 429440da7f489d5304026d1e1b7dea86b112fb11 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 13 Aug 2026 22:09:40 +0530 Subject: [PATCH 07/11] feat: implement manual BLE disconnection logic, restartable GATT server, and improved connection state handling in tile service. --- .../airsync/data/ble/BleConnectionManager.kt | 29 +++++++++++++------ .../airsync/data/ble/BleTransportBridge.kt | 4 +++ .../ui/components/SettingsView.kt | 6 +++- .../ui/components/cards/DeveloperModeCard.kt | 13 +++++++++ .../airsync/service/AirSyncTileService.kt | 5 +++- .../sameerasw/airsync/utils/WebSocketUtil.kt | 11 +++++++ 6 files changed, 57 insertions(+), 11 deletions(-) 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/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index 781354e2..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 { 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..078ec1fe 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 @@ -381,7 +381,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() + } ) } } 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/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/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index aa208b0f..f12fc6f8 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -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 { From 6abc6e9cdca6123632475d264fcb6ec6ec0c8495 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 13 Aug 2026 22:32:34 +0530 Subject: [PATCH 08/11] feat: improve device country code detection by prioritizing SIM and network ISO codes --- .../sameerasw/airsync/utils/ContactLookupHelper.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 } From 886ab29d1e16e69ba4b6f3b0eaf5ed6081cb775a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 13 Aug 2026 23:21:33 +0530 Subject: [PATCH 09/11] fix: reset manual disconnect status upon successful BLE authentication --- .../main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt | 1 + 1 file changed, 1 insertion(+) 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 c16f4205..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 @@ -505,6 +505,7 @@ class BleGattServer(private val context: Context) { byteArrayOf(BleConstants.AUTH_SUCCESS) ) BleTransportBridge.sendDeviceName() + dataStoreManager.setUserManuallyDisconnected(false) startHeartbeat() } else { Log.w(TAG, "BLE Auth Failed! Token mismatch.") From 9c6d63e8bc71329c6d1f639514e41bc0580d4f71 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 13 Aug 2026 23:29:09 +0530 Subject: [PATCH 10/11] refactor: add network capability checks and dynamic backoff logic to WebSocket reconnection process --- .../sameerasw/airsync/utils/WebSocketUtil.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index f12fc6f8..57b95343 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -905,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() @@ -923,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() @@ -935,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)" @@ -948,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) } } From 260b1f327a5ce6b15d6d74f9e864bd93ae57080c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 13 Aug 2026 23:58:14 +0530 Subject: [PATCH 11/11] feat: implement ability to exclude specific media apps from now playing sync #124 --- .../airsync/data/local/DataStoreManager.kt | 17 +++++ .../data/repository/AirSyncRepositoryImpl.kt | 8 +++ .../sameerasw/airsync/domain/model/UiState.kt | 1 + .../domain/repository/AirSyncRepository.kt | 3 + .../ui/components/SettingsView.kt | 21 ++++++ .../ui/components/cards/MediaSyncCard.kt | 14 ++++ .../viewmodel/AirSyncViewModel.kt | 66 +++++++++++++++++++ .../service/MediaNotificationListener.kt | 12 +++- app/src/main/res/values/strings.xml | 2 + 9 files changed, 142 insertions(+), 2 deletions(-) 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/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 078ec1fe..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 @@ -534,6 +535,11 @@ fun SettingsView( isMacMediaControlsEnabled = uiState.isMacMediaControlsEnabled, onToggleMacMediaControls = { enabled -> viewModel.setMacMediaControlsEnabled(enabled) + }, + onOpenExcludedApps = { + HapticUtil.performClick(haptics) + viewModel.loadMediaApps(context) + showMediaAppSelectionSheet = true } ) @@ -638,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/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/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/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 1420e663..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) { 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