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..b99220f5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 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 @@ -7,7 +7,7 @@ Min : Android 11 ## 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/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..5fc1f56f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please reach out to mail@sameerasw.com or GitHub issues if acceptable. diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 207f5738..8e6bf3e9 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -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", 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/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 25735d2e..96e51742 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 @@ -314,7 +314,7 @@ fun SettingsView( deviceInfo.name, deviceInfo.localIp, uiState.port.toIntOrNull() ?: 6996, - versionName ?: "2.0.0", + versionName ?: "3.0.0", adbPorts ) onSendMessage(message) 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 29bd3835..75a7ab95 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 @@ -160,7 +160,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/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/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index 0709674e..fca3916b 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 */ 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..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 */ @@ -198,10 +233,11 @@ 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) + idMapPrefs()?.edit()?.remove(sbn.key)?.apply() } testNotificationIds.remove(id) } 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 3f9e439d..c7da09a7 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) { @@ -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" } @@ -559,7 +561,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4df28bf9..46ca5e24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ Disconnect Device discovery Allow other devices to find this phone in the background - Psst... You\'ve been syncing with your mac for a while now, Do you like the app? Rate and give your feedback. ♥ + Psst... You\'ve been syncing with your Mac for a while now, Do you like the app? Rate and give your feedback. ♥ Rate Now Maybe Later No device connected @@ -62,10 +62,10 @@ by sameerasw.com Let\'s Begin Permissions - This app allows you to sync notifications, clipboard, and media controls with your Mac. To provide these features, AirSync requires certain permissions.\n\nYou have full control over which features are enabled and which permissions are granted. We do not collect or store any of your personal data.\n\nAirSync is open source and this app is completly free to use. Always ensure you download it from Play Store or GitHub. + This app allows you to sync notifications, clipboard, and media controls with your Mac. To provide these features, AirSync requires certain permissions.\n\nYou have full control over which features are enabled and which permissions are granted. We do not collect or store any of your personal data.\n\nAirSync is open source and this app is completely free to use. Always ensure you download it from Play Store or GitHub. If you need any help, feel free to check the guides or reach out to the developer. I Understand - From the scan button in app or by long pressing the connection quick settings tile, scan the QR code displayed on the app on MacOS. + From the scan button in app or by long pressing the connection quick settings tile, scan the QR code displayed on the app on macOS. You can always find help and guides in the app settings. Let Me In Already Preferences