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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ 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)


## Features
- QR Code / Google Lens pairing for quick setup
- Real-time notification mirroring and actionable replies
- Media playback and volume control from Mac
- Cross-device clipboard synchronization
- **Cellular Network Monitoring**: View your Android's cellular status (LTE, 5G, No Signal) directly from your Mac's menu bar.
- **Auto-Start Support**: Enhanced background stability with auto-start helpers for various Android OEMs (Xiaomi, OPPO, Vivo, Letv, etc.).

## How to connect?
Use your built-in camera or Google Lens or anything that can scan a QR code. It will prompt you to open the app. Once authorized, the last device will be saved on the mobile for now for easier reconnection.

Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/com/sameerasw/airsync/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
}
}
}
25 changes: 18 additions & 7 deletions app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
Expand Down Expand Up @@ -121,6 +120,7 @@ class BleGattServer(private val context: Context) {
pendingServices.clear()
_connectionState.value = BleConnectionState.DISCONNECTED
isAuthenticated = false
authenticatedFlag.set(false)
isAdvertisingPaused = false
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -497,12 +498,14 @@ class BleGattServer(private val context: Context) {
if (token.contentEquals(expectedToken.toByteArray(Charsets.UTF_8))) {
Log.i(TAG, "BLE Auth Success!")
isAuthenticated = true
authenticatedFlag.set(true)
_connectionState.value = BleConnectionState.AUTHENTICATED
sendNotification(
BleConstants.CHAR_AUTH_RESULT,
byteArrayOf(BleConstants.AUTH_SUCCESS)
)
BleTransportBridge.sendDeviceName()
dataStoreManager.setUserManuallyDisconnected(false)
startHeartbeat()
} else {
Log.w(TAG, "BLE Auth Failed! Token mismatch.")
Expand Down Expand Up @@ -642,10 +645,17 @@ class BleGattServer(private val context: Context) {
isSending[uuid] = true

val characteristic = findCharacteristic(uuid) ?: return
characteristic.value = data

connectedDevices.forEach { device ->
gattServer?.notifyCharacteristicChanged(device, characteristic, false)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
connectedDevices.forEach { device ->
gattServer?.notifyCharacteristicChanged(device, characteristic, false, data)
}
} else {
@Suppress("DEPRECATION")
characteristic.value = data
connectedDevices.forEach { device ->
@Suppress("DEPRECATION")
gattServer?.notifyCharacteristicChanged(device, characteristic, false)
}
}
}

Expand Down Expand Up @@ -698,6 +708,7 @@ class BleGattServer(private val context: Context) {
}
}
isAuthenticated = false
authenticatedFlag.set(false)
_connectionState.value = BleConnectionState.DISCONNECTED
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -186,9 +190,13 @@ object BleTransportBridge {
if (parts.size >= 2) {
val id = parts[0]
val actionName = parts[1]
// Reply text is optional and was previously dropped, so inline replies
// never worked over BLE. Empty means "plain button", not an empty reply.
val replyText = parts.getOrNull(2)?.takeIf { it.isNotEmpty() }
com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction(
id,
actionName
actionName,
replyText
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -250,6 +253,20 @@ class DataStoreManager(private val context: Context) {
}
}

// Excluded media packages
suspend fun setExcludedMediaPackages(packages: Set<String>) {
context.dataStore.edit { preferences ->
preferences[EXCLUDED_MEDIA_PACKAGES] = packages.joinToString(",")
}
}

fun getExcludedMediaPackages(): Flow<Set<String>> {
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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ class AirSyncRepositoryImpl(
return dataStoreManager.getSendNowPlayingEnabled()
}

override suspend fun setExcludedMediaPackages(packages: Set<String>) {
dataStoreManager.setExcludedMediaPackages(packages)
}

override fun getExcludedMediaPackages(): Flow<Set<String>> {
return dataStoreManager.getExcludedMediaPackages()
}

// New: Keep previous link setting
override suspend fun setKeepPreviousLinkEnabled(enabled: Boolean) {
dataStoreManager.setKeepPreviousLinkEnabled(enabled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ data class UiState(
val manualIsPlus: Boolean = false,
val isContinueBrowsingEnabled: Boolean = true,
val isSendNowPlayingEnabled: Boolean = true,
val excludedMediaPackages: Set<String> = emptySet(),
val isKeepPreviousLinkEnabled: Boolean = true,
val isSmartspacerShowWhenDisconnected: Boolean = false,
val isMacMediaControlsEnabled: Boolean = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ interface AirSyncRepository {
suspend fun setSendNowPlayingEnabled(enabled: Boolean)
fun getSendNowPlayingEnabled(): Flow<Boolean>

suspend fun setExcludedMediaPackages(packages: Set<String>)
fun getExcludedMediaPackages(): Flow<Set<String>>

// Keep previous link settings
suspend fun setKeepPreviousLinkEnabled(enabled: Boolean)
fun getKeepPreviousLinkEnabled(): Flow<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@file:Suppress("DEPRECATION")
package com.sameerasw.airsync.presentation.ui.components

import android.content.Intent
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@file:Suppress("DEPRECATION")
package com.sameerasw.airsync.presentation.ui.components

import android.graphics.Bitmap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@file:Suppress("DEPRECATION")
package com.sameerasw.airsync.presentation.ui.components

import android.view.HapticFeedbackConstants
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -381,7 +382,11 @@ fun SettingsView(
onClearIconSyncMessage = {
viewModel.clearIconSyncMessage()
},
isConnected = uiState.isConnected
isConnected = uiState.isConnected,
onRestartBleServer = {
com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.restartServer()
Toast.makeText(context, "BLE GATT Server restarted", Toast.LENGTH_SHORT).show()
}
)
}
}
Expand Down Expand Up @@ -530,6 +535,11 @@ fun SettingsView(
isMacMediaControlsEnabled = uiState.isMacMediaControlsEnabled,
onToggleMacMediaControls = { enabled ->
viewModel.setMacMediaControlsEnabled(enabled)
},
onOpenExcludedApps = {
HapticUtil.performClick(haptics)
viewModel.loadMediaApps(context)
showMediaAppSelectionSheet = true
}
)

Expand Down Expand Up @@ -634,6 +644,21 @@ fun SettingsView(
isLoading = apps.isEmpty()
)
}

if (showMediaAppSelectionSheet) {
val mediaApps by viewModel.mediaApps.collectAsState()
AppSelectionSheet(
onDismissRequest = { showMediaAppSelectionSheet = false },
apps = mediaApps,
onAppToggle = { pkg, isExcluded ->
viewModel.toggleExcludedMediaPackage(pkg, isExcluded)
},
onSaveAll = { updatedList ->
viewModel.saveAllMediaApps(updatedList)
},
isLoading = mediaApps.isEmpty()
)
}
}

@Composable
Expand Down
Loading