Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# 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

[<img src="https://steverichey.github.io/google-play-badge-svg/img/en_get.svg" width="30%" />](https://play.google.com/store/apps/details?id=com.sameerasw.airsync)


## 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/)

Expand Down
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Security Policy

## Reporting a Vulnerability

Please reach out to mail@sameerasw.com or GitHub issues if acceptable.
8 changes: 4 additions & 4 deletions app/src/main/java/com/sameerasw/airsync/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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()
}

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 0 additions & 6 deletions app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, StatusBarNotification>()

Expand Down Expand Up @@ -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")
Expand All @@ -48,6 +54,7 @@ object NotificationDismissalUtil {
oldestKeys.forEach { oldId ->
activeNotifications.remove(oldId)?.let { sbn ->
keyToId.remove(sbn.key)
idMapPrefs()?.edit()?.remove(sbn.key)?.apply()
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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<String>) {
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
*/
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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"
}
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<string name="disconnect">Disconnect</string>
<string name="device_discovery">Device discovery</string>
<string name="device_discovery_description">Allow other devices to find this phone in the background</string>
<string name="rating_prompt">Psst... You\'ve been syncing with your mac for a while now, Do you like the app? Rate and give your feedback. ♥</string>
<string name="rating_prompt">Psst... You\'ve been syncing with your Mac for a while now, Do you like the app? Rate and give your feedback. ♥</string>
<string name="rate_now">Rate Now</string>
<string name="maybe_later">Maybe Later</string>
<string name="no_device_connected">No device connected</string>
Expand Down Expand Up @@ -62,10 +62,10 @@
<string name="welcome_developer_attribution">by sameerasw.com</string>
<string name="action_lets_begin">Let\'s Begin</string>
<string name="acknowledgement_title">Permissions</string>
<string name="acknowledgement_desc">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.</string>
<string name="acknowledgement_desc">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.</string>
<string name="acknowledgement_footer">If you need any help, feel free to check the guides or reach out to the developer.</string>
<string name="action_i_understand">I Understand</string>
<string name="feature_intro_desc">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.</string>
<string name="feature_intro_desc">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.</string>
<string name="feature_intro_footer">You can always find help and guides in the app settings.</string>
<string name="action_let_me_in">Let Me In Already</string>
<string name="preferences_title">Preferences</string>
Expand Down