From fac1ca040e4b1c7d3c21945d90bab9a194b9c8af Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 13 May 2026 21:45:31 +0530 Subject: [PATCH 01/33] feat: add support for playback duration, elapsed time, and rate to media session updates --- .../airsync/domain/model/MacDeviceStatus.kt | 6 +- .../airsync/service/MacMediaPlayerService.kt | 101 +++++++++++++++--- .../airsync/utils/MacDeviceStatusManager.kt | 24 ++++- .../airsync/utils/WebSocketMessageHandler.kt | 10 +- 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt index 7a22fcab..43bb6dfb 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt @@ -18,5 +18,9 @@ data class MacMusicInfo( val volume: Int, val isMuted: Boolean, val albumArt: String, - val likeStatus: String + val likeStatus: String, + val elapsedTime: Long = 0L, + val duration: Long = 0L, + val timestamp: String? = null, + val playbackRate: Double = 1.0 ) diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt index 7526353c..2f3a3520 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt @@ -27,6 +27,10 @@ class MacMediaPlayerService : Service() { const val EXTRA_ARTIST = "artist" const val EXTRA_IS_PLAYING = "is_playing" const val EXTRA_ALBUM_ART = "album_art" + const val EXTRA_ELAPSED_TIME = "elapsed_time" + const val EXTRA_DURATION = "duration" + const val EXTRA_TIMESTAMP = "timestamp" + const val EXTRA_PLAYBACK_RATE = "playback_rate" private const val TAG = "MacMediaPlayerService" private const val NOTIFICATION_ID = 1001 @@ -39,14 +43,21 @@ class MacMediaPlayerService : Service() { title: String, artist: String, isPlaying: Boolean, - albumArt: Bitmap? + albumArt: Bitmap?, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 ) { val intent = Intent(context, MacMediaPlayerService::class.java).apply { action = ACTION_START_MAC_MEDIA putExtra(EXTRA_TITLE, title) putExtra(EXTRA_ARTIST, artist) putExtra(EXTRA_IS_PLAYING, isPlaying) - // Note: Bitmap cannot be passed via Intent, we'll handle it separately + putExtra(EXTRA_ELAPSED_TIME, elapsedTime) + putExtra(EXTRA_DURATION, duration) + putExtra(EXTRA_TIMESTAMP, timestamp) + putExtra(EXTRA_PLAYBACK_RATE, playbackRate) } context.startForegroundService(intent) serviceInstance?.updateAlbumArt(albumArt) @@ -57,13 +68,21 @@ class MacMediaPlayerService : Service() { title: String, artist: String, isPlaying: Boolean, - albumArt: Bitmap? + albumArt: Bitmap?, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 ) { val intent = Intent(context, MacMediaPlayerService::class.java).apply { action = ACTION_UPDATE_MAC_MEDIA putExtra(EXTRA_TITLE, title) putExtra(EXTRA_ARTIST, artist) putExtra(EXTRA_IS_PLAYING, isPlaying) + putExtra(EXTRA_ELAPSED_TIME, elapsedTime) + putExtra(EXTRA_DURATION, duration) + putExtra(EXTRA_TIMESTAMP, timestamp) + putExtra(EXTRA_PLAYBACK_RATE, playbackRate) } context.startService(intent) serviceInstance?.updateAlbumArt(albumArt) @@ -93,14 +112,24 @@ class MacMediaPlayerService : Service() { val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val artist = intent.getStringExtra(EXTRA_ARTIST) ?: "" val isPlaying = intent.getBooleanExtra(EXTRA_IS_PLAYING, false) - startMacMediaSession(title, artist, isPlaying) + val elapsedTime = intent.getLongExtra(EXTRA_ELAPSED_TIME, 0L) + val duration = intent.getLongExtra(EXTRA_DURATION, 0L) + val timestamp = intent.getStringExtra(EXTRA_TIMESTAMP) + val playbackRate = intent.getDoubleExtra(EXTRA_PLAYBACK_RATE, 1.0) + + startMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) } ACTION_UPDATE_MAC_MEDIA -> { val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val artist = intent.getStringExtra(EXTRA_ARTIST) ?: "" val isPlaying = intent.getBooleanExtra(EXTRA_IS_PLAYING, false) - updateMacMediaSession(title, artist, isPlaying) + val elapsedTime = intent.getLongExtra(EXTRA_ELAPSED_TIME, 0L) + val duration = intent.getLongExtra(EXTRA_DURATION, 0L) + val timestamp = intent.getStringExtra(EXTRA_TIMESTAMP) + val playbackRate = intent.getDoubleExtra(EXTRA_PLAYBACK_RATE, 1.0) + + updateMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) } ACTION_STOP_MAC_MEDIA -> { @@ -150,7 +179,15 @@ class MacMediaPlayerService : Service() { notificationManager.createNotificationChannel(channel) } - private fun startMacMediaSession(title: String, artist: String, isPlaying: Boolean) { + private fun startMacMediaSession( + title: String, + artist: String, + isPlaying: Boolean, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 + ) { try { if (mediaSession == null) { mediaSession = MediaSessionCompat(this, "MacMediaPlayer").apply { @@ -181,8 +218,8 @@ class MacMediaPlayerService : Service() { } } - updateMediaMetadata(title, artist) - updatePlaybackState(isPlaying) + updateMediaMetadata(title, artist, duration) + updatePlaybackState(isPlaying, elapsedTime, timestamp, playbackRate) mediaSession?.isActive = true val notification = createMediaNotification(title, artist, isPlaying) @@ -194,10 +231,18 @@ class MacMediaPlayerService : Service() { } } - private fun updateMacMediaSession(title: String, artist: String, isPlaying: Boolean) { + private fun updateMacMediaSession( + title: String, + artist: String, + isPlaying: Boolean, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 + ) { try { - updateMediaMetadata(title, artist) - updatePlaybackState(isPlaying) + updateMediaMetadata(title, artist, duration) + updatePlaybackState(isPlaying, elapsedTime, timestamp, playbackRate) val notification = createMediaNotification(title, artist, isPlaying) val notificationManager = getSystemService(NotificationManager::class.java) @@ -209,12 +254,12 @@ class MacMediaPlayerService : Service() { } } - private fun updateMediaMetadata(title: String, artist: String) { + private fun updateMediaMetadata(title: String, artist: String, duration: Long = 0L) { val metadataBuilder = MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Playing on Mac") - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 180000) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) currentAlbumArt?.let { bitmap -> metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) @@ -223,7 +268,12 @@ class MacMediaPlayerService : Service() { mediaSession?.setMetadata(metadataBuilder.build()) } - private fun updatePlaybackState(isPlaying: Boolean) { + private fun updatePlaybackState( + isPlaying: Boolean, + elapsedTime: Long = -1L, + timestamp: String? = null, + playbackRate: Double = 1.0 + ) { val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED val actions = PlaybackStateCompat.ACTION_PLAY_PAUSE or @@ -231,9 +281,24 @@ class MacMediaPlayerService : Service() { PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_STOP + // Parse ISO8601 timestamp to calculate elapsed time since reporting + var position = if (elapsedTime >= 0) elapsedTime else (mediaSession?.controller?.playbackState?.position ?: 0L) + if (isPlaying && !timestamp.isNullOrEmpty()) { + try { + val reportedAt = java.time.Instant.parse(timestamp).toEpochMilli() + val now = System.currentTimeMillis() + val diff = now - reportedAt + if (diff > 0) { + position += (diff * playbackRate).toLong() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse timestamp: $timestamp") + } + } + mediaSession?.setPlaybackState( PlaybackStateCompat.Builder() - .setState(state, 30000L, if (isPlaying) 1.0f else 0.0f) + .setState(state, position, playbackRate.toFloat()) .setActions(actions) .build() ) @@ -346,7 +411,11 @@ class MacMediaPlayerService : Service() { val artist = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST) ?: "" val isPlaying = it.controller.playbackState?.state == PlaybackStateCompat.STATE_PLAYING - updateMacMediaSession(title, artist, isPlaying) + val duration = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) + val position = it.controller.playbackState?.position ?: 0L + val speed = it.controller.playbackState?.playbackSpeed?.toDouble() ?: 1.0 + + updateMacMediaSession(title, artist, isPlaying, position, duration, null, speed) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index fb930087..1804b4f2 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -38,7 +38,11 @@ object MacDeviceStatusManager { volume: Int, isMuted: Boolean, albumArt: String?, - likeStatus: String + likeStatus: String, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 ) { try { val effectiveAlbumArt = albumArt ?: _macDeviceStatus.value?.music?.albumArt ?: "" @@ -51,7 +55,11 @@ object MacDeviceStatusManager { volume = volume, isMuted = isMuted, albumArt = effectiveAlbumArt, - likeStatus = likeStatus + likeStatus = likeStatus, + elapsedTime = elapsedTime, + duration = duration, + timestamp = timestamp, + playbackRate = playbackRate ) val status = MacDeviceStatus( @@ -77,7 +85,17 @@ object MacDeviceStatusManager { val isEssentialsEnabled = ds.getEssentialsConnectionEnabled().first() if (isConnected && isMediaControlsEnabled && (title.isNotEmpty() || artist.isNotEmpty() || isPlaying)) { - MacMediaPlayerService.startMacMedia(context, title, artist, isPlaying, bitmap) + MacMediaPlayerService.startMacMedia( + context, + title, + artist, + isPlaying, + bitmap, + elapsedTime, + duration, + timestamp, + playbackRate + ) Log.d(TAG, "Started/Updated Mac media player service") } else { MacMediaPlayerService.stopMacMedia(context) 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 441c4820..ba0f46a6 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -450,6 +450,10 @@ object WebSocketMessageHandler { if (music?.has("albumArt") == true) music.optString("albumArt", "") else null val likeStatus = music?.optString("likeStatus", "none") ?: "none" + val elapsedTime = ((music?.optDouble("elapsedTime", 0.0) ?: 0.0) * 1000).toLong() + val duration = ((music?.optDouble("duration", 0.0) ?: 0.0) * 1000).toLong() + val timestamp = music?.optString("timestamp") + val playbackRate = music?.optDouble("playbackRate", 1.0) ?: 1.0 val isPaired = data.optBoolean("isPaired", true) @@ -473,7 +477,11 @@ object WebSocketMessageHandler { volume = volume, isMuted = isMuted, albumArt = albumArt, - likeStatus = likeStatus + likeStatus = likeStatus, + elapsedTime = elapsedTime, + duration = duration, + timestamp = timestamp, + playbackRate = playbackRate ) // Persist a lightweight snapshot for widget consumption and throttle widget refresh From 7e6867a5167b43bdd8c98c86cf9f09e4b570675e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 01:04:28 +0530 Subject: [PATCH 02/33] feat: implement BLE support --- app/src/main/AndroidManifest.xml | 7 + .../java/com/sameerasw/airsync/AirSyncApp.kt | 5 + .../airsync/data/ble/BleChunkUtil.kt | 115 +++++ .../airsync/data/ble/BleConnectionManager.kt | 77 +++ .../airsync/data/ble/BleConstants.kt | 50 ++ .../airsync/data/ble/BleGattServer.kt | 465 ++++++++++++++++++ .../airsync/data/ble/BleTransportBridge.kt | 96 ++++ .../airsync/data/local/DataStoreManager.kt | 15 + .../airsync/domain/model/MacDeviceStatus.kt | 1 + .../sameerasw/airsync/domain/model/UiState.kt | 3 +- .../ui/activities/PermissionsActivity.kt | 21 + .../ui/components/SettingsView.kt | 3 + .../ui/components/cards/BleSyncCard.kt | 60 +++ .../ui/components/dialogs/PermissionDialog.kt | 11 +- .../ui/screens/PermissionsScreen.kt | 14 + .../viewmodel/AirSyncViewModel.kt | 39 +- .../airsync/service/AirSyncTileService.kt | 5 + .../airsync/service/MacMediaPlayerService.kt | 18 +- .../service/MediaNotificationListener.kt | 48 +- .../com/sameerasw/airsync/utils/JsonUtil.kt | 8 +- .../airsync/utils/MacDeviceStatusManager.kt | 81 ++- .../sameerasw/airsync/utils/PermissionUtil.kt | 21 + .../sameerasw/airsync/utils/SyncManager.kt | 67 ++- .../airsync/utils/WebSocketMessageHandler.kt | 1 + .../sameerasw/airsync/utils/WebSocketUtil.kt | 109 +++- .../res/drawable/rounded_bluetooth_24.xml | 20 + .../rounded_bluetooth_searching_24.xml | 20 + 27 files changed, 1306 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt create mode 100644 app/src/main/res/drawable/rounded_bluetooth_24.xml create mode 100644 app/src/main/res/drawable/rounded_bluetooth_searching_24.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 973848ef..4030914d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,13 @@ + + + + + + + diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 085c026f..a01c427b 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -10,16 +10,21 @@ import kotlinx.coroutines.runBlocking class AirSyncApp : Application() { private var activityCount = 0 + private lateinit var bleConnectionManager: com.sameerasw.airsync.data.ble.BleConnectionManager companion object { private var instance: AirSyncApp? = null fun isAppForeground(): Boolean = instance?.isForeground() ?: false + fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = instance?.bleConnectionManager } override fun onCreate() { super.onCreate() instance = this initSentry() + + bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this) + bleConnectionManager.start() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) { diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt new file mode 100644 index 00000000..7504492c --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt @@ -0,0 +1,115 @@ +package com.sameerasw.airsync.data.ble + +import android.util.Log + +object BleChunkUtil { + private const val TAG = "BleChunkUtil" + + /** + * Splits a string payload into chunks suitable for BLE transmission. + * Each chunk starts with a 2-byte header: [currentIndex, totalChunks] + */ + fun splitIntoChunks(payload: String, mtu: Int): List { + val data = payload.toByteArray(Charsets.UTF_8) + val maxPayloadSize = mtu - BleConstants.CHUNK_HEADER_SIZE + + if (maxPayloadSize <= 0) { + Log.e(TAG, "MTU too small: $mtu") + return emptyList() + } + + val totalChunks = (data.size + maxPayloadSize - 1) / maxPayloadSize + val chunks = mutableListOf() + + for (i in 0 until totalChunks) { + val start = i * maxPayloadSize + val end = minOf(start + maxPayloadSize, data.size) + val chunkData = data.sliceArray(start until end) + + val chunk = ByteArray(BleConstants.CHUNK_HEADER_SIZE + chunkData.size) + val buffer = java.nio.ByteBuffer.wrap(chunk) + buffer.putShort(i.toShort()) + buffer.putShort(totalChunks.toShort()) + + chunkData.copyInto(chunk, BleConstants.CHUNK_HEADER_SIZE) + chunks.add(chunk) + } + + return chunks + } + + /** + * Reassembles chunks into the original string. + * Expects a map of index to chunk data (without the header). + */ + fun reassemble(chunks: Map): String { + val sortedIndices = chunks.keys.sorted() + if (sortedIndices.isEmpty()) return "" + + val totalSize = chunks.values.sumOf { it.size } + val result = ByteArray(totalSize) + + var offset = 0 + for (index in sortedIndices) { + val chunk = chunks[index] ?: continue + chunk.copyInto(result, offset) + offset += chunk.size + } + + return String(result, Charsets.UTF_8) + } + + /** + * Extracts header information from a raw BLE packet. + */ + fun parseHeader(packet: ByteArray): Pair? { + if (packet.size < BleConstants.CHUNK_HEADER_SIZE) return null + val buffer = java.nio.ByteBuffer.wrap(packet) + val current = buffer.short.toInt() and 0xFFFF + val total = buffer.short.toInt() and 0xFFFF + return Pair(current, total) + } + + /** + * Extracts payload data from a raw BLE packet (strips header). + */ + fun getPayload(packet: ByteArray): ByteArray { + if (packet.size <= BleConstants.CHUNK_HEADER_SIZE) return byteArrayOf() + return packet.sliceArray(BleConstants.CHUNK_HEADER_SIZE until packet.size) + } + + /** + * Helper class to reassemble chunks as they arrive. + */ + class Reassembler { + private val chunks = mutableMapOf() + private var totalChunks = -1 + + fun addChunk(packet: ByteArray): String? { + val header = parseHeader(packet) ?: return null + val current = header.first + val total = header.second + + if (totalChunks != -1 && totalChunks != total) { + // New transmission started or mismatch, reset + chunks.clear() + } + totalChunks = total + + chunks[current] = getPayload(packet) + + if (chunks.size == totalChunks) { + val result = reassemble(chunks) + chunks.clear() + totalChunks = -1 + return result + } + return null + } + + fun clear() { + chunks.clear() + totalChunks = -1 + } + } +} 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 new file mode 100644 index 00000000..92b66e3b --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt @@ -0,0 +1,77 @@ +package com.sameerasw.airsync.data.ble + +import android.content.Context +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.WebSocketUtil +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +class BleConnectionManager(private val context: Context) { + companion object { + private const val TAG = "BleConnectionManager" + } + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val dataStoreManager = DataStoreManager(context) + private var bleServer: BleGattServer? = null + + private var isBleEnabled = false + + @OptIn(ExperimentalCoroutinesApi::class) + private val _serverFlow = kotlinx.coroutines.flow.MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val connectionState = _serverFlow.flatMapLatest { server -> + server?.connectionState ?: kotlinx.coroutines.flow.MutableStateFlow(BleGattServer.BleConnectionState.DISCONNECTED) + } + + fun start() { + if (bleServer == null) { + bleServer = BleGattServer(context) + _serverFlow.value = bleServer + BleTransportBridge.initialize(bleServer!!) + } + + scope.launch { + combine( + dataStoreManager.getBleSyncEnabled(), + dataStoreManager.getBleAutoConnectEnabled(), + WebSocketUtil.connectionState + ) { enabled, auto, wsState -> + Triple(enabled, auto, wsState) + }.collectLatest { (enabled, _, _) -> + isBleEnabled = enabled + updateBleState() + } + } + } + + private fun updateBleState() { + if (isBleEnabled) { + Log.d(TAG, "BLE enabled, starting GATT server") + bleServer?.start() + } else { + Log.d(TAG, "BLE disabled, stopping server") + bleServer?.stop() + } + } + + fun stop() { + scope.cancel() + bleServer?.stop() + } + + val isAuthenticated: Boolean + get() = bleServer?.isAuthenticated ?: false + + fun sendChunkedNotification(characteristicUuid: java.util.UUID, payload: String) { + bleServer?.sendChunkedNotification(characteristicUuid, payload) + } + + fun sendNotification(characteristicUuid: java.util.UUID, data: ByteArray) { + bleServer?.sendNotification(characteristicUuid, data) + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt new file mode 100644 index 00000000..78d5c7c9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt @@ -0,0 +1,50 @@ +package com.sameerasw.airsync.data.ble + +import java.util.UUID + +object BleConstants { + private const val UUID_BASE = "-7461-4694-8146-2162624a682c" + + // Services + val SERVICE_SYSTEM = UUID.fromString("a1520001$UUID_BASE") + val SERVICE_NOTIFICATIONS = UUID.fromString("a1520002$UUID_BASE") + val SERVICE_MEDIA = UUID.fromString("a1520003$UUID_BASE") + val SERVICE_CLIPBOARD = UUID.fromString("a1520004$UUID_BASE") + + // System Characteristics + val CHAR_PROTOCOL_VERSION = UUID.fromString("a1520101$UUID_BASE") + val CHAR_AUTH_TOKEN = UUID.fromString("a1520102$UUID_BASE") + val CHAR_AUTH_RESULT = UUID.fromString("a1520103$UUID_BASE") + val CHAR_BATTERY_LEVEL = UUID.fromString("a1520104$UUID_BASE") + val CHAR_MAC_BATTERY = UUID.fromString("a1520105$UUID_BASE") + val CHAR_SYSTEM_STATE = UUID.fromString("a1520106$UUID_BASE") + val CHAR_MAC_CONTROL = UUID.fromString("a1520107$UUID_BASE") + val CHAR_DEVICE_NAME = UUID.fromString("a1520108$UUID_BASE") + + // Notification Characteristics + val CHAR_NOTIFICATION_DATA = UUID.fromString("a1520201$UUID_BASE") + val CHAR_NOTIFICATION_ACTION = UUID.fromString("a1520202$UUID_BASE") + val CHAR_NOTIFICATION_DISMISS = UUID.fromString("a1520203$UUID_BASE") + val CHAR_NOTIFICATION_DISMISS_NOTIFY = UUID.fromString("a1520204$UUID_BASE") + + // Media Characteristics + val CHAR_MEDIA_STATE = UUID.fromString("a1520301$UUID_BASE") + val CHAR_MEDIA_CONTROL = UUID.fromString("a1520302$UUID_BASE") + val CHAR_MAC_MEDIA_STATE = UUID.fromString("a1520303$UUID_BASE") + + // Clipboard Characteristics + val CHAR_CLIPBOARD_DATA_NOTIFY = UUID.fromString("a1520401$UUID_BASE") + val CHAR_CLIPBOARD_DATA_WRITE = UUID.fromString("a1520402$UUID_BASE") + + // Protocol Constants + const val PROTOCOL_VERSION = 1 + const val AUTH_SUCCESS: Byte = 0x01 + const val AUTH_FAILED: Byte = 0x00 + + // Chunking + const val MAX_MTU = 512 + const val CHUNK_HEADER_SIZE = 4 // [index: UInt16][total: UInt16] + + // Delimiter for compact strings + const val DELIMITER = "\u001F" +} 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 new file mode 100644 index 00000000..7e1330dc --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -0,0 +1,465 @@ +package com.sameerasw.airsync.data.ble + +import android.annotation.SuppressLint +import android.bluetooth.* +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.content.Context +import android.os.ParcelUuid +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.NotificationDismissalUtil +import com.sameerasw.airsync.utils.ClipboardSyncManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue + +@SuppressLint("MissingPermission") +class BleGattServer(private val context: Context) { + companion object { + private const val TAG = "BleGattServer" + private var instance: BleGattServer? = null + fun isAnyAuthenticated(): Boolean = instance?.isAuthenticated ?: false + } + + init { + instance = this + } + + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val adapter = bluetoothManager.adapter + private var gattServer: BluetoothGattServer? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dataStoreManager = DataStoreManager(context) + + private val _connectionState = MutableStateFlow(BleConnectionState.DISCONNECTED) + val connectionState = _connectionState.asStateFlow() + + private val connectedDevices = mutableSetOf() + private val characteristicQueues = mutableMapOf>() + private val isSending = mutableMapOf() + + var isAuthenticated = false + private set + private var negotiatedMtu = 23 + + enum class BleConnectionState { + DISCONNECTED, ADVERTISING, CONNECTED, AUTHENTICATED + } + + private val pendingServices = mutableListOf() + + /** + * Start the GATT server and begin advertising + */ + fun start() { + if (_connectionState.value != BleConnectionState.DISCONNECTED) { + Log.d(TAG, "BLE GATT Server already starting or started") + return + } + + if (!com.sameerasw.airsync.utils.PermissionUtil.isBluetoothPermissionsGranted(context)) { + Log.e(TAG, "Missing Bluetooth permissions, cannot start BLE transport") + return + } + + if (adapter == null || !adapter.isEnabled) { + Log.e(TAG, "Bluetooth adapter not available or disabled") + return + } + + setupGattServer() + } + + /** + * Stop the GATT server and advertising + */ + fun stop() { + stopAdvertising() + gattServer?.clearServices() + gattServer?.close() + gattServer = null + connectedDevices.clear() + pendingServices.clear() + _connectionState.value = BleConnectionState.DISCONNECTED + isAuthenticated = false + } + + private fun setupGattServer() { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + + pendingServices.clear() + + // System Service + val systemService = BluetoothGattService(BleConstants.SERVICE_SYSTEM, BluetoothGattService.SERVICE_TYPE_PRIMARY) + systemService.addCharacteristic(createReadCharacteristic(BleConstants.CHAR_PROTOCOL_VERSION)) + systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_AUTH_TOKEN)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_AUTH_RESULT)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_BATTERY_LEVEL)) + systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_BATTERY)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_SYSTEM_STATE)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MAC_CONTROL)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_DEVICE_NAME)) + pendingServices.add(systemService) + + // Notifications Service + val notifService = BluetoothGattService(BleConstants.SERVICE_NOTIFICATIONS, BluetoothGattService.SERVICE_TYPE_PRIMARY) + notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DATA)) + notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_ACTION)) + notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS)) + notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY)) + pendingServices.add(notifService) + + // Media Service + val mediaService = BluetoothGattService(BleConstants.SERVICE_MEDIA, BluetoothGattService.SERVICE_TYPE_PRIMARY) + mediaService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MEDIA_STATE)) + mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MEDIA_CONTROL)) + mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_MEDIA_STATE)) + pendingServices.add(mediaService) + + // Clipboard Service + val clipService = BluetoothGattService(BleConstants.SERVICE_CLIPBOARD, BluetoothGattService.SERVICE_TYPE_PRIMARY) + clipService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY)) + clipService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_WRITE)) + pendingServices.add(clipService) + + // Add first service with a small delay for stability + scope.launch(Dispatchers.Main) { + delay(300) + if (pendingServices.isNotEmpty()) { + val first = pendingServices.removeAt(0) + gattServer?.addService(first) + } + } + } + + private var currentAdvertiseCallback: AdvertiseCallback? = null + + private fun startAdvertising() { + if (currentAdvertiseCallback != null) { + stopAdvertising() + } + + val advertiser = adapter.bluetoothLeAdvertiser ?: return + + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceUuid(ParcelUuid(BleConstants.SERVICE_SYSTEM)) + .build() + + val scanResponse = AdvertiseData.Builder() + .setIncludeDeviceName(true) + .build() + + val callback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + Log.d(TAG, "BLE Advertising started successfully") + _connectionState.value = BleConnectionState.ADVERTISING + } + + override fun onStartFailure(errorCode: Int) { + if (errorCode == ADVERTISE_FAILED_ALREADY_STARTED) { + Log.d(TAG, "BLE Advertising already started, treating as success") + _connectionState.value = BleConnectionState.ADVERTISING + } else { + Log.e(TAG, "BLE Advertising failed: $errorCode") + currentAdvertiseCallback = null + _connectionState.value = BleConnectionState.DISCONNECTED + } + } + } + + currentAdvertiseCallback = callback + advertiser.startAdvertising(settings, data, scanResponse, callback) + } + + private fun stopAdvertising() { + val callback = currentAdvertiseCallback ?: return + adapter.bluetoothLeAdvertiser?.stopAdvertising(callback) + currentAdvertiseCallback = null + } + + private val gattServerCallback = object : BluetoothGattServerCallback() { + override fun onServiceAdded(status: Int, service: BluetoothGattService) { + Log.d(TAG, "Service added: ${service.uuid}, status: $status") + if (pendingServices.isNotEmpty()) { + val next = pendingServices.removeAt(0) + gattServer?.addService(next) + } else { + Log.d(TAG, "All services added, starting advertising") + startAdvertising() + } + } + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + Log.d(TAG, "onConnectionStateChange: device=${device.address}, status=$status, newState=$newState, bond=${device.bondState}") + + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Device connected: ${device.address}") + connectedDevices.add(device) + _connectionState.value = BleConnectionState.CONNECTED + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Device disconnected: ${device.address}") + connectedDevices.remove(device) + if (connectedDevices.isEmpty()) { + _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED + isAuthenticated = false + } + } + } + + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) { + Log.d(TAG, "MTU changed for ${device.address}: $mtu") + negotiatedMtu = mtu + } + + override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic) { + if (characteristic.uuid == BleConstants.CHAR_PROTOCOL_VERSION) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, byteArrayOf(BleConstants.PROTOCOL_VERSION.toByte())) + } else { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, 0, null) + } + } + + private val chunkBuffers = mutableMapOf>() + + override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}") + + when (characteristic.uuid) { + BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) + BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) + BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(characteristic.uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(characteristic.uuid, value) { handleMacMediaState(it) } + BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(characteristic.uuid, value) { + Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}") + ClipboardSyncManager.handleClipboardUpdate(context, it) + } + BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(characteristic.uuid, value) { + Log.d(TAG, "Received Mac Device Name: $it") + // Update Mac name in status manager + MacDeviceStatusManager.updateMacStatus(context, name = it) + } + BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationDismiss(it) } + else -> Log.w(TAG, "Unknown characteristic write: ${characteristic.uuid}") + } + + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + + private fun handleChunkedWrite(uuid: UUID, value: ByteArray, onComplete: (String) -> Unit) { + val header = BleChunkUtil.parseHeader(value) + if (header == null) { + // Not chunked or invalid header - maybe small payload? + // For now, assume everything to these characteristics is chunked. + return + } + val (current, total) = header + val payload = BleChunkUtil.getPayload(value) + + val buffer = chunkBuffers.getOrPut(uuid) { mutableMapOf() } + buffer[current] = payload + + if (buffer.size == total) { + val completePayload = BleChunkUtil.reassemble(buffer) + chunkBuffers.remove(uuid) + onComplete(completePayload) + } + } + + override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, descriptor: BluetoothGattDescriptor) { + Log.d(TAG, "Descriptor read request: ${descriptor.uuid}") + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) + } + + override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + Log.d(TAG, "Descriptor write request: ${descriptor.uuid}, value: ${value.contentToString()}") + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + // This is crucial for sequential chunk sending + processNextInQueues() + } + } + + private fun handleAuthRequest(device: BluetoothDevice, token: ByteArray) { + scope.launch { + val deviceData = dataStoreManager.getLastConnectedDevice().first() + val storedKey = deviceData?.symmetricKey + Log.d(TAG, "Handling auth request from ${device.address}. Device in DB: ${deviceData?.name}, hasKey: ${storedKey != null}") + + if (storedKey != null) { + val expectedToken = BleTransportBridge.deriveAuthToken(storedKey) + val receivedTokenStr = String(token, Charsets.UTF_8) + + Log.d(TAG, "Expected token: $expectedToken") + Log.d(TAG, "Received token: $receivedTokenStr") + + if (token.contentEquals(expectedToken.toByteArray(Charsets.UTF_8))) { + Log.i(TAG, "BLE Auth Success!") + isAuthenticated = true + _connectionState.value = BleConnectionState.AUTHENTICATED + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS)) + BleTransportBridge.sendDeviceName() + } else { + Log.w(TAG, "BLE Auth Failed! Token mismatch.") + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + } + } else { + Log.w(TAG, "BLE Auth Failed! No symmetric key found for last connected device.") + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + } + } + } + + private fun handleNotificationDismiss(id: String) { + Log.d(TAG, "Handling notification dismissal from BLE: $id") + NotificationDismissalUtil.dismissNotification(id) + } + + private fun handleMacBattery(value: ByteArray) { + if (!isAuthenticated) return + val payload = String(value, Charsets.UTF_8) + val parts = payload.split("|") + if (parts.size >= 2) { + val level = parts[0].toIntOrNull() ?: -1 + val isCharging = parts[1] == "1" + Log.d(TAG, "Received Mac battery level via BLE: $level%, charging: $isCharging") + MacDeviceStatusManager.updateBatteryStatus(context, level, isCharging) + } else if (value.size == 1) { + // Legacy 1-byte fallback + val level = value[0].toInt() and 0xFF + MacDeviceStatusManager.updateBatteryStatus(context, level, false) + } + } + + private fun handleMacMediaState(payload: String) { + if (!isAuthenticated) return + val parts = payload.split("|") + if (parts.size >= 6) { + val isPlaying = parts[0] == "1" + val title = parts[1] + val artist = parts[2] + val volume = parts[3].toIntOrNull() ?: 0 + val isMuted = parts[4] == "1" + val likeStatus = parts[5] + + Log.d(TAG, "Received Mac media state via BLE: $title by $artist (Playing: $isPlaying)") + MacDeviceStatusManager.updateMusicStatus( + context, isPlaying, title, artist, volume, isMuted, likeStatus + ) + } + } + + private fun handleNotificationAction(value: ByteArray) { + if (!isAuthenticated) return + val data = String(value, Charsets.UTF_8) + BleTransportBridge.handleNotificationAction(data, context) + } + + private fun handleMediaControl(value: ByteArray) { + if (!isAuthenticated) return + val action = String(value, Charsets.UTF_8) + BleTransportBridge.handleMediaControl(action, context) + } + + /** + * Send a notification to all connected devices (with chunking) + */ + fun sendNotification(characteristicUuid: UUID, data: ByteArray) { + if (connectedDevices.isEmpty()) return + + // Characteristic level queue to ensure order + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } + queue.add(data) + + if (isSending[characteristicUuid] != true) { + processNextInQueue(characteristicUuid) + } + } + + fun sendChunkedNotification(characteristicUuid: UUID, payload: String) { + if (connectedDevices.isEmpty()) return + + // Truncate notification text to conserve BLE bandwidth + val truncatedPayload = if (characteristicUuid == BleConstants.CHAR_NOTIFICATION_DATA) { + payload.take(500) + } else payload + + val mtu = negotiatedMtu + val chunks = BleChunkUtil.splitIntoChunks(truncatedPayload, mtu) + + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } + chunks.forEach { queue.add(it) } + + if (isSending[characteristicUuid] != true) { + processNextInQueue(characteristicUuid) + } + } + + private fun processNextInQueues() { + characteristicQueues.keys.forEach { uuid -> + if (isSending[uuid] == true) { + isSending[uuid] = false + processNextInQueue(uuid) + } + } + } + + private fun processNextInQueue(uuid: UUID) { + val queue = characteristicQueues[uuid] ?: return + val data = queue.poll() ?: return + + isSending[uuid] = true + + val characteristic = findCharacteristic(uuid) ?: return + characteristic.value = data + + connectedDevices.forEach { device -> + gattServer?.notifyCharacteristicChanged(device, characteristic, false) + } + } + + private fun findCharacteristic(uuid: UUID): BluetoothGattCharacteristic? { + gattServer?.services?.forEach { service -> + service.getCharacteristic(uuid)?.let { return it } + } + return null + } + + private fun createReadCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + return BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ) + } + + private fun createWriteCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + return BluetoothGattCharacteristic(uuid, + BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE) + } + + private fun createNotifyCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + val char = BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ) + // Add CCCD for notification support + val configDescriptor = BluetoothGattDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE) + char.addDescriptor(configDescriptor) + return char + } +} 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 new file mode 100644 index 00000000..f3c0c847 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -0,0 +1,96 @@ +package com.sameerasw.airsync.data.ble + +import android.util.Log +import com.sameerasw.airsync.domain.model.BatteryInfo +import com.sameerasw.airsync.domain.model.AudioInfo +import java.security.MessageDigest +import java.util.* + +object BleTransportBridge { + private const val TAG = "BleTransportBridge" + + private var gattServer: BleGattServer? = null + + fun initialize(server: BleGattServer) { + gattServer = server + } + + fun deriveAuthToken(symmetricKey: String): String { + return try { + val md = MessageDigest.getInstance("SHA-256") + val hash = md.digest(symmetricKey.toByteArray(Charsets.UTF_8)) + Base64.getEncoder().encodeToString(hash.copyOf(16)) + } catch (e: Exception) { + Log.e(TAG, "Error deriving auth token: ${e.message}") + "" + } + } + + // --- Outbound (Android -> Mac) --- + + fun sendNotification(pkg: String, title: String, text: String) { + val payload = listOf(pkg, title, text).joinToString(BleConstants.DELIMITER) + gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DATA, payload) + } + + fun sendBatteryStatus(battery: BatteryInfo) { + val level = battery.level.toByte() + gattServer?.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level)) + } + + fun sendMediaState(audio: AudioInfo) { + val payload = listOf( + if (audio.isPlaying) "1" else "0", + audio.title, + audio.artist, + audio.volume.toString(), + if (audio.isMuted) "1" else "0", + audio.likeStatus + ).joinToString(BleConstants.DELIMITER) + + gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) + } + + fun sendSystemState(isDnd: Boolean, isPowerSave: Boolean) { + val payload = listOf( + if (isDnd) "1" else "0", + if (isPowerSave) "1" else "0" + ).joinToString(BleConstants.DELIMITER) + + gattServer?.sendNotification(BleConstants.CHAR_SYSTEM_STATE, payload.toByteArray()) + } + + fun sendClipboard(text: String) { + gattServer?.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, text) + } + + fun sendNotificationDismissal(id: String) { + gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) + } + + fun sendDeviceName() { + val name = android.os.Build.MODEL + gattServer?.sendChunkedNotification(BleConstants.CHAR_DEVICE_NAME, name) + } + + // --- Inbound (Mac -> Android) --- + + fun handleMediaControl(action: String, context: android.content.Context) { + Log.d(TAG, "Media control from BLE: $action") + when (action) { + "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) + "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) + "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) + } + } + + fun handleNotificationAction(data: String, context: android.content.Context) { + Log.d(TAG, "Notification action from BLE: $data") + val parts = data.split(BleConstants.DELIMITER) + if (parts.size >= 2) { + val id = parts[0] + val actionName = parts[1] + com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction(id, actionName) + } + } +} 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 0fd57735..78538e83 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 @@ -98,6 +98,9 @@ class DataStoreManager(private val context: Context) { private val REMOTE_FLIPPED = booleanPreferencesKey("remote_flipped") + private val BLE_SYNC_ENABLED = booleanPreferencesKey("ble_sync_enabled") + private val BLE_AUTO_CONNECT_ENABLED = booleanPreferencesKey("ble_auto_connect_enabled") + private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -1010,4 +1013,16 @@ class DataStoreManager(private val context: Context) { prefs[ESSENTIALS_CONNECTION_ENABLED] ?: false } } + + suspend fun setBleSyncEnabled(enabled: Boolean) { + context.dataStore.edit { it[BLE_SYNC_ENABLED] = enabled } + } + + fun getBleSyncEnabled(): Flow = context.dataStore.data.map { it[BLE_SYNC_ENABLED] ?: false } + + suspend fun setBleAutoConnectEnabled(enabled: Boolean) { + context.dataStore.edit { it[BLE_AUTO_CONNECT_ENABLED] = enabled } + } + + fun getBleAutoConnectEnabled(): Flow = context.dataStore.data.map { it[BLE_AUTO_CONNECT_ENABLED] ?: true } } diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt index 43bb6dfb..312f704c 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt @@ -1,6 +1,7 @@ package com.sameerasw.airsync.domain.model data class MacDeviceStatus( + val name: String = "Unknown", val battery: MacBattery, val isPaired: Boolean, val music: MacMusicInfo 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 79c93973..cb64daeb 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 @@ -49,5 +49,6 @@ data class UiState( val isSentryReportingEnabled: Boolean = true, val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, - val isQuickShareEnabled: Boolean = false + val isQuickShareEnabled: Boolean = false, + val bleConnectionState: com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState = com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.DISCONNECTED ) \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt index 97d0940c..27c199b9 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt @@ -51,6 +51,10 @@ class PermissionsActivity : ComponentActivity() { ActivityResultContracts.RequestPermission() ) { refreshUI() } + private val bluetoothPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { refreshUI() } + private var refreshCounter by mutableStateOf(0) @OptIn(ExperimentalMaterial3Api::class) @@ -120,6 +124,9 @@ class PermissionsActivity : ComponentActivity() { onRequestPhonePermission = { requestPhonePermission() }, + onRequestBluetoothPermission = { + requestBluetoothPermission() + }, refreshTrigger = refreshCounter ) } @@ -156,6 +163,20 @@ class PermissionsActivity : ComponentActivity() { phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) } } + + private fun requestBluetoothPermission() { + if (!PermissionUtil.isBluetoothPermissionsGranted(this)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissionLauncher.launch( + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + ) + ) + } + } + } override fun onResume() { super.onResume() 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 993a4add..18d675f9 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState +import com.sameerasw.airsync.presentation.ui.components.cards.BleSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard import com.sameerasw.airsync.presentation.ui.components.cards.DefaultTabCard import com.sameerasw.airsync.presentation.ui.components.cards.DeveloperModeCard @@ -269,6 +270,8 @@ fun SettingsView( title = "Quick Share", subtitle = "Allow receiving files from nearby devices" ) + + BleSyncCard(viewModel = viewModel) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt new file mode 100644 index 00000000..11e71931 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt @@ -0,0 +1,60 @@ +package com.sameerasw.airsync.presentation.ui.components.cards + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.data.ble.BleGattServer +import kotlinx.coroutines.launch + +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import com.sameerasw.airsync.R + +@Composable +fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel) { + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val scope = rememberCoroutineScope() + + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) + + val uiState by viewModel.uiState.collectAsState() + val bleState = uiState.bleConnectionState + + val statusText = when (bleState) { + BleGattServer.BleConnectionState.DISCONNECTED -> "Secondary transport for notifications" + BleGattServer.BleConnectionState.ADVERTISING -> "Advertising (Waiting for Mac...)" + BleGattServer.BleConnectionState.CONNECTED -> "Connected (Authenticating...)" + BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected & Authenticated" + } + + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = "Bluetooth LE Sync", + description = statusText, + isChecked = bleEnabled, + onCheckedChange = { + scope.launch { dataStoreManager.setBleSyncEnabled(it) } + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_searching_24, + title = "BLE Auto-connect", + description = "Automatically switch to Bluetooth when Wi-Fi drops", + isChecked = autoConnect, + onCheckedChange = { + scope.launch { dataStoreManager.setBleAutoConnectEnabled(it) } + }, + enabled = bleEnabled + ) + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt index cd7ff320..180a1ca4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt @@ -35,7 +35,8 @@ enum class PermissionType { WALLPAPER_ACCESS, CALL_LOG, CONTACTS, - PHONE + PHONE, + BLUETOOTH } data class PermissionInfo( @@ -211,5 +212,13 @@ private fun getPermissionInfo(permissionType: PermissionType): PermissionInfo { whyNeeded = "This permission allows AirSync to detect when your phone is ringing, when you answer, or when a call ends, so it can display a live call status on your Mac. \n\nAirSync NEVER accesses your call audio or records conversations. This is used solely to facilitate the remote call notification feature as a device companion.", buttonText = "Grant Phone Access" ) + + PermissionType.BLUETOOTH -> PermissionInfo( + title = "Bluetooth Access", + icon = R.drawable.rounded_sync_desktop_24, + description = "AirSync uses Bluetooth Low Energy (BLE) as a secondary transport to sync notifications and media controls with your Mac when Wi-Fi is unavailable.", + whyNeeded = "To discover and connect to your Mac via Bluetooth, Android requires Bluetooth permissions (Scan, Connect, and Advertise). \n\nThis enables a low-power background connection that keeps your devices synced even when they aren't on the same Wi-Fi network. AirSync only uses Bluetooth to communicate with your authorized Mac devices.", + buttonText = "Grant Bluetooth Access" + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt index 1a04029f..7ee62dc6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt @@ -43,6 +43,7 @@ fun PermissionsScreen( onRequestCallLogPermission: (() -> Unit)? = null, onRequestContactsPermission: (() -> Unit)? = null, onRequestPhonePermission: (() -> Unit)? = null, + onRequestBluetoothPermission: (() -> Unit)? = null, refreshTrigger: Int = 0 ) { val context = LocalContext.current @@ -219,6 +220,15 @@ fun PermissionsScreen( isCritical = false ) } + + "Bluetooth Access" -> { + PermissionButton( + permissionName = permission, + description = "Enables background BLE sync", + onExplainClick = { showDialog = PermissionType.BLUETOOTH }, + isCritical = false + ) + } } } } @@ -263,6 +273,10 @@ fun PermissionsScreen( PermissionType.PHONE -> { onRequestPhonePermission?.invoke() } + + PermissionType.BLUETOOTH -> { + onRequestBluetoothPermission?.invoke() + } } } ) 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 bd8ba0fe..ae16cb96 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 @@ -94,24 +94,27 @@ class AirSyncViewModel( } // Connection status listener for WebSocket updates - private val connectionStatusListener: (Boolean) -> Unit = { isConnected -> + private val connectionStatusListener: (Boolean) -> Unit = { isWsConnected -> viewModelScope.launch { + val isBleConnected = _uiState.value.bleConnectionState == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isGlobalConnected = isWsConnected || isBleConnected + _uiState.value = _uiState.value.copy( - isConnected = isConnected, + isConnected = isGlobalConnected, isConnecting = false, - response = if (isConnected) "Connected successfully!" else "Disconnected", - activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null, - macDeviceStatus = if (isConnected) _uiState.value.macDeviceStatus else null + response = if (isGlobalConnected) "Connected successfully!" else "Disconnected", + activeIp = if (isWsConnected) WebSocketUtil.currentIpAddress else null, + macDeviceStatus = if (isGlobalConnected) _uiState.value.macDeviceStatus else null ) - if (isConnected) { + if (isGlobalConnected) { repository.setFirstMacConnectionTime(System.currentTimeMillis()) updateRatingPromptDisplay() } // Update dynamic shortcuts appContext?.let { ctx -> - ShortcutUtil.refreshShortcuts(ctx, isConnected) + ShortcutUtil.refreshShortcuts(ctx, isGlobalConnected) } // Notify Smartspacer of connection status change @@ -180,6 +183,28 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isQuickShareEnabled = enabled) } } + + // Observe BLE connection status + viewModelScope.launch { + com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.connectionState?.collect { state -> + Log.d("AirSyncViewModel", "BLE connection state changed: $state") + val isBleAuthenticated = state == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isWsConnected = WebSocketUtil.isConnected() + + _uiState.value = _uiState.value.copy( + bleConnectionState = state, + isConnected = isWsConnected || isBleAuthenticated + ) + + if (isBleAuthenticated && !isWsConnected) { + // Refresh shortcuts and other side effects if this is the only connection + appContext?.let { ctx -> + ShortcutUtil.refreshShortcuts(ctx, true) + } + updateRatingPromptDisplay() + } + } + } } override fun onCleared() { 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 e7fe404d..f8fa40e1 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -202,6 +202,11 @@ class AirSyncTileService : TileService() { "Connected" } } ?: "Connected" + } else if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && lastDevice != null) { + // BLE Connected state + state = Tile.STATE_ACTIVE + label = lastDevice.name + subtitle = "Connected BT" } else if (isAuto) { // Auto-reconnect in progress or waiting state = if (isConnecting) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt index 2f3a3520..5f91fdbe 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt @@ -107,7 +107,19 @@ class MacMediaPlayerService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { + val action = intent?.action + Log.d(TAG, "onStartCommand: action=$action") + + if (action == ACTION_STOP_MAC_MEDIA || action == null) { + if (mediaSession == null) { + val notification = createMediaNotification("", "", false) + startForeground(NOTIFICATION_ID, notification) + } + stopMacMediaSession() + return START_NOT_STICKY + } + + when (action) { ACTION_START_MAC_MEDIA -> { val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val artist = intent.getStringExtra(EXTRA_ARTIST) ?: "" @@ -131,10 +143,6 @@ class MacMediaPlayerService : Service() { updateMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) } - - ACTION_STOP_MAC_MEDIA -> { - stopMacMediaSession() - } // Handle media control actions from notification buttons "MAC_MEDIA_play" -> { sendMacMediaControl("play") 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 0ae06ea9..76f1a7f9 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -499,41 +499,38 @@ class MediaNotificationListener : NotificationListenerService() { ) } - // Send dismissal update to Mac for real removals + // Sync dismissal to Mac serviceScope.launch { try { - val id = NotificationDismissalUtil.getIdForSbn(sbn) - if (id.isNullOrEmpty()) { - // Skip if we cannot map to a known id - return@launch - } + val id = NotificationDismissalUtil.getIdForSbn(sbn) ?: sbn.key + // If this dismissal was initiated by our own dismiss request, skip echo val wasSuppressed = NotificationDismissalUtil.consumeSuppressed(id) if (wasSuppressed) { - // Clean local caches and ignore + Log.d(TAG, "Dismissal for $id was suppressed (originated from Mac)") NotificationDismissalUtil.removeFromCaches(id) return@launch } - // Build and send update - if (WebSocketUtil.isConnected()) { - val update = JsonUtil.toSingleLine( - JsonUtil.createNotificationUpdateJson( - id, - dismissed = true, - action = "dismiss" - ) + // Build and send update via WebSocket + val updateJson = JsonUtil.toSingleLine( + JsonUtil.createNotificationUpdateJson( + id, + dismissed = true, + action = "dismiss" ) - val sent = WebSocketUtil.sendMessage(update) - Log.d(TAG, "Sent notificationUpdate for $id: $sent") - } else { - Log.d(TAG, "WebSocket not connected; skipping notificationUpdate for $id") - } + ) + WebSocketUtil.sendMessage(updateJson) + + // Also sync via BLE + com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotificationDismissal(id) + + Log.d(TAG, "Sent notification removal sync for $id (WS and BLE)") // Remove from caches since it's gone now NotificationDismissalUtil.removeFromCaches(id) } catch (e: Exception) { - Log.e(TAG, "Error sending notificationUpdate: ${e.message}") + Log.e(TAG, "Error syncing notification removal: ${e.message}") } } } @@ -667,8 +664,15 @@ class MediaNotificationListener : NotificationListenerService() { Log.e(TAG, "Failed to send notification via WebSocket") } } else { - Log.d(TAG, "WebSocket not connected, skipping notification sync") + Log.d(TAG, "WebSocket not connected, skipping notification sync via WS") } + + // Always attempt BLE sync if any device is connected + com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotification( + pkg = sbn.packageName, + title = title, + text = body + ) } else { Log.d(TAG, "Skipping empty notification from ${sbn.packageName}") } 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 e6536f1c..8817fc4b 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -61,6 +61,7 @@ object JsonUtil { version: String, wallpaperBase64: String?, adbPorts: List, + bleAuthToken: String? = null, targetIpAddress: String? = null ): String { val wallpaperJson = if (wallpaperBase64 != null) { @@ -68,10 +69,15 @@ object JsonUtil { } else { "" } + val bleTokenJson = if (bleAuthToken != null) { + ""","bleAuthToken":"$bleAuthToken"""" + } else { + "" + } val portsJson = adbPorts.joinToString(",") { "\"$it\"" } val targetIpJson = if (targetIpAddress != null) """, "targetIpAddress": "$targetIpAddress" """ else "" - return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$wallpaperJson$targetIpJson}}""" + return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$wallpaperJson$bleTokenJson$targetIpJson}}""" } /** diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index 1804b4f2..c2120cbe 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -6,6 +6,7 @@ import android.graphics.BitmapFactory import android.util.Base64 import android.util.Log import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.data.ble.BleGattServer import com.sameerasw.airsync.domain.model.MacBattery import com.sameerasw.airsync.domain.model.MacDeviceStatus import com.sameerasw.airsync.domain.model.MacMusicInfo @@ -27,8 +28,83 @@ object MacDeviceStatusManager { private val _albumArt = MutableStateFlow(null) val albumArt: StateFlow = _albumArt.asStateFlow() + fun updateBatteryStatus(context: Context, level: Int, isCharging: Boolean) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = current?.name ?: "Unknown", + batteryLevel = level, + isCharging = isCharging, + isPaired = current?.isPaired ?: true, + isPlaying = current?.music?.isPlaying ?: false, + title = current?.music?.title ?: "", + artist = current?.music?.artist ?: "", + volume = current?.music?.volume ?: 0, + isMuted = current?.music?.isMuted ?: false, + albumArt = null, // keep current + likeStatus = current?.music?.likeStatus ?: "none", + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + + fun updateMacStatus(context: Context, name: String) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = name, + batteryLevel = current?.battery?.level ?: -1, + isCharging = current?.battery?.isCharging ?: false, + isPaired = current?.isPaired ?: true, + isPlaying = current?.music?.isPlaying ?: false, + title = current?.music?.title ?: "", + artist = current?.music?.artist ?: "", + volume = current?.music?.volume ?: 0, + isMuted = current?.music?.isMuted ?: false, + albumArt = null, // keep current + likeStatus = current?.music?.likeStatus ?: "none", + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + + fun updateMusicStatus( + context: Context, + isPlaying: Boolean, + title: String, + artist: String, + volume: Int, + isMuted: Boolean, + likeStatus: String + ) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = current?.name ?: "Unknown", + batteryLevel = current?.battery?.level ?: -1, + isCharging = current?.battery?.isCharging ?: false, + isPaired = current?.isPaired ?: true, + isPlaying = isPlaying, + title = title, + artist = artist, + volume = volume, + isMuted = isMuted, + albumArt = null, // keep current + likeStatus = likeStatus, + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + fun updateStatus( context: Context, + name: String, batteryLevel: Int, isCharging: Boolean, isPaired: Boolean, @@ -63,6 +139,7 @@ object MacDeviceStatusManager { ) val status = MacDeviceStatus( + name = name, battery = macBattery, isPaired = isPaired, music = macMusicInfo @@ -81,7 +158,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { val ds = DataStoreManager(context) val isMediaControlsEnabled = ds.getMacMediaControlsEnabled().first() - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnected() || WebSocketUtil.isConnecting() || BleGattServer.isAnyAuthenticated() val isEssentialsEnabled = ds.getEssentialsConnectionEnabled().first() if (isConnected && isMediaControlsEnabled && (title.isNotEmpty() || artist.isNotEmpty() || isPlaying)) { @@ -193,7 +270,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { try { // Check current state - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnected() || BleGattServer.isAnyAuthenticated() val currentStatus = _macDeviceStatus.value if (isConnected && currentStatus != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt index ecd8b1f1..16a53e45 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -187,6 +187,10 @@ object PermissionUtil { missing.add("Phone Access") } + if (!isBluetoothPermissionsGranted(context)) { + missing.add("Bluetooth Access") + } + return missing } @@ -240,6 +244,10 @@ object PermissionUtil { optional.add("Phone Access") } + if (!isBluetoothPermissionsGranted(context)) { + optional.add("Bluetooth Access") + } + return optional } @@ -272,4 +280,17 @@ object PermissionUtil { Manifest.permission.READ_PHONE_STATE ) == PackageManager.PERMISSION_GRANTED } + + /** + * Check if Bluetooth permissions are granted (Connect and Advertise/Scan on Android 12+) + */ + fun isBluetoothPermissionsGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED + } else { + // On older versions, manifest permissions are enough + true + } + } } 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 3da42518..e3adde92 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -110,17 +110,32 @@ object SyncManager { shouldSync = true // First time } - if (shouldSync && WebSocketUtil.isConnected()) { - val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) - val success = WebSocketUtil.sendMessage(statusJson) + if (shouldSync) { + if (WebSocketUtil.isConnected()) { + val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) + val success = WebSocketUtil.sendMessage(statusJson) + + if (success) { + // Log.d(TAG, "Device status synced successfully") + lastAudioInfo = currentAudio + lastBatteryInfo = currentBattery + lastVolume = currentAudio.volume + } else { + Log.w(TAG, "Failed to sync device status") + } + } - if (success) { - // Log.d(TAG, "Device status synced successfully") + // Always sync via BLE if possible + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + + // Also update cache if BLE is used, to avoid redundant BLE sends + // (Actually we already updated it above if WS succeeded, + // but if WS failed and BLE succeeded, we should still update it) + if (!WebSocketUtil.isConnected()) { lastAudioInfo = currentAudio lastBatteryInfo = currentBattery lastVolume = currentAudio.volume - } else { - Log.w(TAG, "Failed to sync device status") } } @@ -189,17 +204,22 @@ object SyncManager { Log.d(TAG, "Discovered ADB ports: $adbPorts") val deviceId = DeviceInfoUtil.getDeviceId(context) + val symmetricKey = dataStoreManager.getLastConnectedDevice().first()?.symmetricKey ?: "" + val bleAuthToken = com.sameerasw.airsync.data.ble.BleTransportBridge.deriveAuthToken(symmetricKey) + val liteDeviceInfoJson = JsonUtil.createDeviceInfoJson( - deviceId, - deviceName, - localIp, - port, - version, - adbPorts, - WebSocketUtil.currentIpAddress + id = deviceId, + name = deviceName, + ipAddress = localIp, + port = port, + version = version, + wallpaperBase64 = null, + adbPorts = adbPorts, + bleAuthToken = bleAuthToken, + targetIpAddress = WebSocketUtil.currentIpAddress ) if (WebSocketUtil.sendMessage(liteDeviceInfoJson)) { - Log.d(TAG, "Lite device info sent to trigger macInfo") + Log.d(TAG, "Lite device info sent to trigger macInfo (BLE token included)") } else { Log.e(TAG, "Failed to send lite device info") } @@ -220,14 +240,15 @@ object SyncManager { } val currentAdbPorts = discoveredServices.map { it.port.toString() } val fullDeviceInfoJson = JsonUtil.createDeviceInfoJson( - deviceId, - deviceName, - localIp, - port, - version, - wallpaperBase64, - currentAdbPorts, - WebSocketUtil.currentIpAddress + id = deviceId, + name = deviceName, + ipAddress = localIp, + port = port, + version = version, + wallpaperBase64 = wallpaperBase64, + adbPorts = currentAdbPorts, + bleAuthToken = bleAuthToken, + targetIpAddress = WebSocketUtil.currentIpAddress ) if (WebSocketUtil.sendMessage(fullDeviceInfoJson)) { Log.d( 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 ba0f46a6..cfaf25d9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -468,6 +468,7 @@ object WebSocketMessageHandler { // Update the Mac device status manager with all media info MacDeviceStatusManager.updateStatus( context = context, + name = data.optString("name", MacDeviceStatusManager.macDeviceStatus.value?.name ?: "Unknown"), batteryLevel = batteryLevel, isCharging = isCharging, isPaired = isPaired, 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 ba6e47ae..03295d5d 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -2,18 +2,22 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log +import com.sameerasw.airsync.data.ble.BleConstants import com.sameerasw.airsync.widget.AirSyncWidgetProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import org.json.JSONObject import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -32,6 +36,11 @@ object WebSocketUtil { private val isConnected = AtomicBoolean(false) private val isConnecting = AtomicBoolean(false) + private fun updateConnectedStatus(status: Boolean) { + isConnected.set(status) + _connectionStateFlow.value = status + } + // Transport state: true after OkHttp onOpen, false after closing/failure/disconnect private val isSocketOpen = AtomicBoolean(false) private val handshakeCompleted = AtomicBoolean(false) @@ -57,6 +66,9 @@ object WebSocketUtil { // Global connection status listeners for UI updates private val connectionStatusListeners = mutableSetOf<(Boolean) -> Unit>() + private val _connectionStateFlow = MutableStateFlow(false) + val connectionState = _connectionStateFlow.asStateFlow() + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -229,7 +241,7 @@ object WebSocketUtil { WebSocketUtil.webSocket = webSocket currentIpAddress = ip // Store the successful IP isSocketOpen.set(true) - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(true) try { @@ -243,7 +255,7 @@ object WebSocketUtil { delay(HANDSHAKE_TIMEOUT_MS) if (!handshakeCompleted.get()) { Log.w(TAG, "Handshake timed out") - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) try { webSocket.close(4001, "Handshake timeout") @@ -293,7 +305,7 @@ object WebSocketUtil { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { } - isConnected.set(true) + updateConnectedStatus(true) isConnecting.set(false) handshakeTimeoutJob?.cancel() try { @@ -364,7 +376,7 @@ object WebSocketUtil { } } } - isConnected.set(false) + updateConnectedStatus(false) isSocketOpen.set(false) isConnecting.set(false) handshakeCompleted.set(false) @@ -419,7 +431,7 @@ object WebSocketUtil { } } } - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) @@ -511,17 +523,90 @@ object WebSocketUtil { */ fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) - return if (isSocketOpen.get() && webSocket != null) { - Log.d(TAG, "Sending message: $message") + if (isSocketOpen.get() && webSocket != null) { + Log.d(TAG, "Sending message via WebSocket: $message") val messageToSend = currentSymmetricKey?.let { key -> CryptoUtil.encryptMessage(message, key) } ?: message - webSocket!!.send(messageToSend) + return webSocket!!.send(messageToSend) } else { - Log.w(TAG, "WebSocket not connected, cannot send message") - false + // Fallback to BLE if authenticated + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() + if (ble != null && ble.isAuthenticated) { + Log.d(TAG, "WebSocket not connected, falling back to BLE: $message") + return sendOverBLE(message) + } + + Log.w(TAG, "Neither WebSocket nor BLE connected, cannot send message") + return false + } + } + + private fun sendOverBLE(message: String): Boolean { + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() ?: return false + try { + val json = JSONObject(message) + val type = json.optString("type") + val data = json.optJSONObject("data") ?: JSONObject() + + when (type) { + "notificationAction" -> { + val pkg = data.optString("package") + val actionId = data.optString("actionId") + val payload = "$pkg|$actionId" + ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_ACTION, payload) + return true + } + "mediaControl" -> { + val action = data.optString("action") + // Protocol: type|action + val payload = "media|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "volumeControl" -> { + val action = data.optString("action") + // Protocol: type|action + val payload = "volume|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "clipboard", "clipboardUpdate" -> { + val content = data.optString("text", data.optString("content")) + ble.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, content) + return true + } + "dismissNotification" -> { + val id = data.optString("id") + ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) + return true + } + "remoteControl" -> { + val action = data.optString("action") + // Filter out high-frequency cursor controls over BLE + if (action == "mouse_move" || action == "mouse_click" || action == "mouse_scroll") { + return false + } + // Include value if present (e.g. vol_set needs level) + val value = if (data.has("value")) data.opt("value")?.toString() else null + val payload = if (value != null) "remote|$action|$value" else "remote|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "status" -> { + val battery = data.optJSONObject("battery") + if (battery != null) { + val level = battery.optInt("level") + ble.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + return true + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error sending over BLE fallback: ${e.message}") } + return false } /** @@ -530,7 +615,7 @@ object WebSocketUtil { */ fun disconnect(context: Context? = null) { Log.d(TAG, "Disconnecting WebSocket") - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) @@ -612,7 +697,7 @@ object WebSocketUtil { } fun isConnected(): Boolean { - return isConnected.get() + return isConnected.get() || com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() } fun isConnecting(): Boolean { diff --git a/app/src/main/res/drawable/rounded_bluetooth_24.xml b/app/src/main/res/drawable/rounded_bluetooth_24.xml new file mode 100644 index 00000000..9600872a --- /dev/null +++ b/app/src/main/res/drawable/rounded_bluetooth_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml new file mode 100644 index 00000000..341352de --- /dev/null +++ b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml @@ -0,0 +1,20 @@ + + + + + + From ee301f4fc4254029e278aeb8d935701e4ffa630b Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 01:16:59 +0530 Subject: [PATCH 03/33] feat: update BLE status labels and move BleSyncCard to a new Bluetooth settings section --- .../airsync/presentation/ui/components/SettingsView.kt | 7 +++++++ .../presentation/ui/components/cards/BleSyncCard.kt | 10 ++++------ 2 files changed, 11 insertions(+), 6 deletions(-) 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 18d675f9..c6389d75 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 @@ -270,7 +270,14 @@ fun SettingsView( title = "Quick Share", subtitle = "Allow receiving files from nearby devices" ) + } + } + + // Integration Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsCategoryTitle("Bluetooth") + RoundedCardContainer { BleSyncCard(viewModel = viewModel) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt index 11e71931..045fb0f1 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt @@ -29,13 +29,12 @@ fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncV val bleState = uiState.bleConnectionState val statusText = when (bleState) { - BleGattServer.BleConnectionState.DISCONNECTED -> "Secondary transport for notifications" - BleGattServer.BleConnectionState.ADVERTISING -> "Advertising (Waiting for Mac...)" - BleGattServer.BleConnectionState.CONNECTED -> "Connected (Authenticating...)" - BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected & Authenticated" + BleGattServer.BleConnectionState.DISCONNECTED -> "For nearby connection" + BleGattServer.BleConnectionState.ADVERTISING -> "Scanning" + BleGattServer.BleConnectionState.CONNECTED -> "Authenticating" + BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected" } - RoundedCardContainer { IconToggleItem( iconRes = R.drawable.rounded_bluetooth_24, title = "Bluetooth LE Sync", @@ -56,5 +55,4 @@ fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncV }, enabled = bleEnabled ) - } } From b36eb79458ae5192ee15f9809b83990fb1a94c4f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 16:24:34 +0530 Subject: [PATCH 04/33] chore: ignore .agents directory and update BLE heartbeat interval and logic in SyncManager --- .gitignore | 1 + .../sameerasw/airsync/utils/SyncManager.kt | 23 ++++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 8d589cd4..9939654b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ app/release local.properties .vscode/launch.json build/reports/problems/problems-report.html +.agents/ \ No newline at end of file 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 e3adde92..897d765c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean object SyncManager { private const val TAG = "SyncManager" - private const val BATTERY_SYNC_INTERVAL = 20_000L // 20 seconds + private const val BATTERY_SYNC_INTERVAL = 10_000L // 10 seconds private const val MAX_DAILY_ICON_SYNCS = 3 private var syncJob: Job? = null @@ -124,19 +124,16 @@ object SyncManager { Log.w(TAG, "Failed to sync device status") } } + } - // Always sync via BLE if possible - com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) - - // Also update cache if BLE is used, to avoid redundant BLE sends - // (Actually we already updated it above if WS succeeded, - // but if WS failed and BLE succeeded, we should still update it) - if (!WebSocketUtil.isConnected()) { - lastAudioInfo = currentAudio - lastBatteryInfo = currentBattery - lastVolume = currentAudio.volume - } + // Heartbeat: Always sync via BLE if possible (even if no change, to reset Mac watchdog) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + + if (shouldSync && !WebSocketUtil.isConnected()) { + lastAudioInfo = currentAudio + lastBatteryInfo = currentBattery + lastVolume = currentAudio.volume } } catch (e: Exception) { From 4f1875cd34c2f3da8c4448a86cf06586e9bf1203 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 16:30:57 +0530 Subject: [PATCH 05/33] feat: send battery heartbeat over BLE and optimize media state updates in SyncManager --- .../airsync/data/ble/BleGattServer.kt | 22 +++++++++++++++++++ .../sameerasw/airsync/utils/SyncManager.kt | 13 ++++++++--- 2 files changed, 32 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 7e1330dc..07f61d7d 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 @@ -47,6 +47,7 @@ class BleGattServer(private val context: Context) { var isAuthenticated = false private set private var negotiatedMtu = 23 + private var heartbeatJob: Job? = null enum class BleConnectionState { DISCONNECTED, ADVERTISING, CONNECTED, AUTHENTICATED @@ -81,6 +82,7 @@ class BleGattServer(private val context: Context) { */ fun stop() { stopAdvertising() + stopHeartbeat() gattServer?.clearServices() gattServer?.close() gattServer = null @@ -214,6 +216,7 @@ class BleGattServer(private val context: Context) { Log.d(TAG, "Device disconnected: ${device.address}") connectedDevices.remove(device) if (connectedDevices.isEmpty()) { + stopHeartbeat() _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false } @@ -319,6 +322,7 @@ class BleGattServer(private val context: Context) { _connectionState.value = BleConnectionState.AUTHENTICATED sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS)) BleTransportBridge.sendDeviceName() + startHeartbeat() } else { Log.w(TAG, "BLE Auth Failed! Token mismatch.") sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) @@ -330,6 +334,24 @@ class BleGattServer(private val context: Context) { } } + private fun startHeartbeat() { + stopHeartbeat() + heartbeatJob = scope.launch { + while (isActive && isAuthenticated) { + delay(5000) + if (connectedDevices.isNotEmpty()) { + val level = com.sameerasw.airsync.utils.DeviceInfoUtil.getBatteryInfo(context).level + sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + } + } + } + } + + private fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + private fun handleNotificationDismiss(id: String) { Log.d(TAG, "Handling notification dismissal from BLE: $id") NotificationDismissalUtil.dismissNotification(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 897d765c..40e0597f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -42,6 +42,12 @@ object SyncManager { while (isActive && isSyncing.get()) { try { + // Heartbeat: Always sync battery over BLE if authenticated to keep Mac connection alive + if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { + val currentBattery = DeviceInfoUtil.getBatteryInfo(context) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + } + // Check if WebSocket is connected and sync is enabled if (WebSocketUtil.isConnected()) { val dataStoreManager = DataStoreManager(context) @@ -126,9 +132,10 @@ object SyncManager { } } - // Heartbeat: Always sync via BLE if possible (even if no change, to reset Mac watchdog) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + // Media state still needs to be sent if it changed + if (shouldSync) { + com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) + } if (shouldSync && !WebSocketUtil.isConnected()) { lastAudioInfo = currentAudio From edc52d0ea162fbaee22b127ddf8e5668c63fb191 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 17:06:47 +0530 Subject: [PATCH 06/33] refactor: centralize BLE transport logic into WebSocketUtil and use constants for parsing --- .../airsync/data/ble/BleGattServer.kt | 4 +- .../service/MediaNotificationListener.kt | 32 ++++----------- .../sameerasw/airsync/utils/SyncManager.kt | 41 +++++++------------ .../sameerasw/airsync/utils/WebSocketUtil.kt | 23 ++++++++++- 4 files changed, 47 insertions(+), 53 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 07f61d7d..9925659b 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 @@ -360,7 +360,7 @@ class BleGattServer(private val context: Context) { private fun handleMacBattery(value: ByteArray) { if (!isAuthenticated) return val payload = String(value, Charsets.UTF_8) - val parts = payload.split("|") + val parts = payload.split(BleConstants.DELIMITER) if (parts.size >= 2) { val level = parts[0].toIntOrNull() ?: -1 val isCharging = parts[1] == "1" @@ -375,7 +375,7 @@ class BleGattServer(private val context: Context) { private fun handleMacMediaState(payload: String) { if (!isAuthenticated) return - val parts = payload.split("|") + val parts = payload.split(BleConstants.DELIMITER) if (parts.size >= 6) { val isPlaying = parts[0] == "1" val title = parts[1] 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 76f1a7f9..8f3be0cc 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -522,10 +522,7 @@ class MediaNotificationListener : NotificationListenerService() { ) WebSocketUtil.sendMessage(updateJson) - // Also sync via BLE - com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotificationDismissal(id) - - Log.d(TAG, "Sent notification removal sync for $id (WS and BLE)") + Log.d(TAG, "Sent notification removal sync for $id") // Remove from caches since it's gone now NotificationDismissalUtil.removeFromCaches(id) @@ -652,27 +649,16 @@ class MediaNotificationListener : NotificationListenerService() { Log.d(TAG, "Preparing to send notification: $notificationJson") - if (WebSocketUtil.isConnected()) { - Log.d(TAG, "Sending notification via WebSocket") - val success = WebSocketUtil.sendMessage(notificationJson) - if (success) { - Log.d( - TAG, - "Notification sent successfully via existing WebSocket connection" - ) - } else { - Log.e(TAG, "Failed to send notification via WebSocket") - } + Log.d(TAG, "Sending notification via WS/BLE") + val success = WebSocketUtil.sendMessage(notificationJson) + if (success) { + Log.d( + TAG, + "Notification sent successfully" + ) } else { - Log.d(TAG, "WebSocket not connected, skipping notification sync via WS") + Log.e(TAG, "Failed to send notification") } - - // Always attempt BLE sync if any device is connected - com.sameerasw.airsync.data.ble.BleTransportBridge.sendNotification( - pkg = sbn.packageName, - title = title, - text = body - ) } else { Log.d(TAG, "Skipping empty notification from ${sbn.packageName}") } 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 40e0597f..3e3b7c8c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -42,14 +42,14 @@ object SyncManager { while (isActive && isSyncing.get()) { try { - // Heartbeat: Always sync battery over BLE if authenticated to keep Mac connection alive - if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { + // Heartbeat: Sync battery over BLE if authenticated and WS not connected to keep Mac connection alive + if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && !WebSocketUtil.isConnected()) { val currentBattery = DeviceInfoUtil.getBatteryInfo(context) com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) } - // Check if WebSocket is connected and sync is enabled - if (WebSocketUtil.isConnected()) { + // Check if sync is needed (either via WebSocket or BLE) + if (WebSocketUtil.isConnected() || com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { val dataStoreManager = DataStoreManager(context) val isSyncEnabled = dataStoreManager.getNotificationSyncEnabled().first() @@ -117,32 +117,19 @@ object SyncManager { } if (shouldSync) { - if (WebSocketUtil.isConnected()) { - val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) - val success = WebSocketUtil.sendMessage(statusJson) - - if (success) { - // Log.d(TAG, "Device status synced successfully") - lastAudioInfo = currentAudio - lastBatteryInfo = currentBattery - lastVolume = currentAudio.volume - } else { - Log.w(TAG, "Failed to sync device status") - } + val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) + // sendMessage handles both WebSocket and BLE fallback internally + val success = WebSocketUtil.sendMessage(statusJson) + + if (success) { + lastAudioInfo = currentAudio + lastBatteryInfo = currentBattery + lastVolume = currentAudio.volume + } else { + Log.w(TAG, "Failed to sync device status (WS/BLE)") } } - // Media state still needs to be sent if it changed - if (shouldSync) { - com.sameerasw.airsync.data.ble.BleTransportBridge.sendMediaState(currentAudio) - } - - if (shouldSync && !WebSocketUtil.isConnected()) { - lastAudioInfo = currentAudio - lastBatteryInfo = currentBattery - lastVolume = currentAudio.volume - } - } catch (e: Exception) { Log.e(TAG, "Error checking device status: ${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 03295d5d..36b28a53 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -3,6 +3,8 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log import com.sameerasw.airsync.data.ble.BleConstants +import com.sameerasw.airsync.data.ble.BleTransportBridge +import com.sameerasw.airsync.domain.model.AudioInfo import com.sameerasw.airsync.widget.AirSyncWidgetProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -594,13 +596,32 @@ object WebSocketUtil { ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) return true } + "notification" -> { + val pkg = data.optString("package") + val title = data.optString("title") + val body = data.optString("body") + BleTransportBridge.sendNotification(pkg, title, body) + return true + } "status" -> { val battery = data.optJSONObject("battery") if (battery != null) { val level = battery.optInt("level") ble.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) - return true } + val music = data.optJSONObject("music") + if (music != null) { + val audio = AudioInfo( + isPlaying = music.optBoolean("isPlaying"), + title = music.optString("title"), + artist = music.optString("artist"), + volume = music.optInt("volume"), + isMuted = music.optBoolean("isMuted"), + likeStatus = music.optString("likeStatus") + ) + BleTransportBridge.sendMediaState(audio) + } + return true } } } catch (e: Exception) { From b5c86e2031ff3daf00fc1ebd93ba01fef41cb1e4 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 17:12:25 +0530 Subject: [PATCH 07/33] feat: include app name parameter in notification payload transmission --- .../java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt | 4 ++-- .../main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 3 ++- 2 files changed, 4 insertions(+), 3 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 f3c0c847..a8003a06 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 @@ -28,8 +28,8 @@ object BleTransportBridge { // --- Outbound (Android -> Mac) --- - fun sendNotification(pkg: String, title: String, text: String) { - val payload = listOf(pkg, title, text).joinToString(BleConstants.DELIMITER) + fun sendNotification(pkg: String, appName: String, title: String, text: String) { + val payload = listOf(pkg, appName, title, text).joinToString(BleConstants.DELIMITER) gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DATA, payload) } 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 36b28a53..78715773 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -598,9 +598,10 @@ object WebSocketUtil { } "notification" -> { val pkg = data.optString("package") + val appName = data.optString("app") val title = data.optString("title") val body = data.optString("body") - BleTransportBridge.sendNotification(pkg, title, body) + BleTransportBridge.sendNotification(pkg, appName, title, body) return true } "status" -> { From 832f56ee722c4fe9c2da0169d00d7254447849bd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 16 May 2026 17:42:36 +0530 Subject: [PATCH 08/33] feat: add albumArtLite support for optimized BLE media state transmission --- .../com/sameerasw/airsync/data/ble/BleGattServer.kt | 3 ++- .../airsync/data/ble/BleTransportBridge.kt | 3 ++- .../sameerasw/airsync/domain/model/DeviceStatus.kt | 2 ++ .../airsync/service/MediaNotificationListener.kt | 13 +++++++++++++ .../com/sameerasw/airsync/utils/DeviceInfoUtil.kt | 3 +++ .../java/com/sameerasw/airsync/utils/JsonUtil.kt | 4 +++- .../airsync/utils/MacDeviceStatusManager.kt | 5 +++-- .../com/sameerasw/airsync/utils/WebSocketUtil.kt | 3 ++- 8 files changed, 30 insertions(+), 6 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 9925659b..d4727d14 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 @@ -383,10 +383,11 @@ class BleGattServer(private val context: Context) { val volume = parts[3].toIntOrNull() ?: 0 val isMuted = parts[4] == "1" val likeStatus = parts[5] + val albumArt = if (parts.size >= 7) parts[6] else null Log.d(TAG, "Received Mac media state via BLE: $title by $artist (Playing: $isPlaying)") MacDeviceStatusManager.updateMusicStatus( - context, isPlaying, title, artist, volume, isMuted, likeStatus + context, isPlaying, title, artist, volume, isMuted, likeStatus, albumArt ) } } 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 a8003a06..72b0ed89 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 @@ -45,7 +45,8 @@ object BleTransportBridge { audio.artist, audio.volume.toString(), if (audio.isMuted) "1" else "0", - audio.likeStatus + audio.likeStatus, + audio.albumArtLite ?: "" ).joinToString(BleConstants.DELIMITER) gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt index 22ad0224..6d942489 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt @@ -12,6 +12,7 @@ data class AudioInfo( val volume: Int, val isMuted: Boolean, val albumArt: String? = null, + val albumArtLite: String? = null, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) @@ -21,6 +22,7 @@ data class MediaInfo( val title: String, val artist: String, val albumArt: String? = null, + val albumArtLite: String? = null, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) \ No newline at end of file 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 8f3be0cc..13366288 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -131,6 +131,18 @@ class MediaNotificationListener : NotificationListenerService() { Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } + val albumArtLiteBase64 = albumArtBitmap?.let { + try { + val outputStream = ByteArrayOutputStream() + // Scale down to 80x80 and lower quality for BLE + val scaled = Bitmap.createScaledBitmap(it, 80, 80, true) + scaled.compress(Bitmap.CompressFormat.JPEG, 30, outputStream) + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) + } catch (e: Exception) { + null + } + } + // Log.d(TAG, "Media session - Title: $title, Artist: $artist, Playing: $isPlaying, State: ${playbackState?.state}") @@ -153,6 +165,7 @@ class MediaNotificationListener : NotificationListenerService() { title = title, artist = artist, albumArt = albumArtBase64, + albumArtLite = albumArtLiteBase64, likeStatus = likeStatus ) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt index 8aac4a56..0658b703 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -151,6 +151,7 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = null, + albumArtLite = null, likeStatus = "none" ) } @@ -166,6 +167,7 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = mediaInfo.albumArt, + albumArtLite = mediaInfo.albumArtLite, likeStatus = mediaInfo.likeStatus ) } catch (e: Exception) { @@ -188,6 +190,7 @@ object DeviceInfoUtil { volume = audioInfo.volume, isMuted = audioInfo.isMuted, albumArt = audioInfo.albumArt, + albumArtLite = audioInfo.albumArtLite, likeStatus = audioInfo.likeStatus ) } 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 8817fc4b..b8466fb0 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -156,10 +156,12 @@ object JsonUtil { volume: Int, isMuted: Boolean, albumArt: String?, + albumArtLite: String? = null, likeStatus: String ): String { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson,"likeStatus":"$likeStatus"}}}""" + 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,"likeStatus":"$likeStatus"}}}""" } /** diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index c2120cbe..65cacb01 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -79,7 +79,8 @@ object MacDeviceStatusManager { artist: String, volume: Int, isMuted: Boolean, - likeStatus: String + likeStatus: String, + albumArt: String? = null ) { val current = _macDeviceStatus.value updateStatus( @@ -93,7 +94,7 @@ object MacDeviceStatusManager { artist = artist, volume = volume, isMuted = isMuted, - albumArt = null, // keep current + albumArt = albumArt, likeStatus = likeStatus, elapsedTime = current?.music?.elapsedTime ?: 0L, duration = current?.music?.duration ?: 0L, 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 78715773..e02f4ecf 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -618,7 +618,8 @@ object WebSocketUtil { artist = music.optString("artist"), volume = music.optInt("volume"), isMuted = music.optBoolean("isMuted"), - likeStatus = music.optString("likeStatus") + likeStatus = music.optString("likeStatus"), + albumArtLite = music.optString("albumArtLite") ) BleTransportBridge.sendMediaState(audio) } From e383a94f67be8f0c3f6438305891e03e2698ecde Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 11:24:18 +0530 Subject: [PATCH 09/33] feat: move auto-reconnect toggle to a new dedicated ConnectionSettingsBottomSheet accessed via LastConnectedDeviceCard --- .../ui/components/SettingsView.kt | 9 -- .../cards/LastConnectedDeviceCard.kt | 74 ++++++++++-- .../sheets/ConnectionSettingsBottomSheet.kt | 112 ++++++++++++++++++ app/src/main/res/values/strings.xml | 10 ++ 4 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt 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 c6389d75..eff639d3 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 @@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState -import com.sameerasw.airsync.presentation.ui.components.cards.BleSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard import com.sameerasw.airsync.presentation.ui.components.cards.DefaultTabCard import com.sameerasw.airsync.presentation.ui.components.cards.DeveloperModeCard @@ -274,14 +273,6 @@ fun SettingsView( } - // Integration Section - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SettingsCategoryTitle("Bluetooth") - RoundedCardContainer { - BleSyncCard(viewModel = viewModel) - } - } - // Integration Section Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsCategoryTitle("Integration") diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 7f216422..a797a9ac 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -1,12 +1,16 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -15,14 +19,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice +import com.sameerasw.airsync.presentation.ui.components.sheets.ConnectionSettingsBottomSheet import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @@ -119,8 +129,6 @@ fun LastConnectedDeviceCard( // Text("Type: $type", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) // } - - Button( onClick = { HapticUtil.performClick(haptics) @@ -140,22 +148,68 @@ fun LastConnectedDeviceCard( Text("Quick Connect") } - // Auto-reconnect toggle + + } + var showBottomSheet by remember { mutableStateOf(false) } + + // Auto-reconnect & Bluetooth settings card + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + HapticUtil.performClick(haptics) + showBottomSheet = true + }, + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { Row( modifier = Modifier - .fillMaxWidth().padding(top = 8.dp), + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Auto reconnect", style = MaterialTheme.typography.bodyMedium) - Switch(checked = isAutoReconnectEnabled, onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_compare_arrows_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary ) - onToggleAutoReconnect(enabled) - }) + Column { + Text( + text = stringResource(R.string.bluetooth_settings_card_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.bluetooth_settings_card_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24), + contentDescription = "Configure settings", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } + if (showBottomSheet) { + ConnectionSettingsBottomSheet( + isAutoReconnectEnabled = isAutoReconnectEnabled, + onToggleAutoReconnect = onToggleAutoReconnect, + onDismissRequest = { showBottomSheet = false } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt new file mode 100644 index 00000000..fc6940fb --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt @@ -0,0 +1,112 @@ +package com.sameerasw.airsync.presentation.ui.components.sheets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionSettingsBottomSheet( + isAutoReconnectEnabled: Boolean, + onToggleAutoReconnect: (Boolean) -> Unit, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val scope = rememberCoroutineScope() + + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.bluetooth_settings_card_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 8.dp) + ) + + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_sync_desktop_24, + title = stringResource(R.string.setting_auto_reconnect_title), + description = stringResource(R.string.setting_auto_reconnect_desc), + isChecked = isAutoReconnectEnabled, + onCheckedChange = { enabled -> + onToggleAutoReconnect(enabled) + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = stringResource(R.string.setting_nearby_connection_title), + description = stringResource(R.string.setting_nearby_connection_desc), + isChecked = bleEnabled, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleSyncEnabled(enabled) + } + } + ) + + AnimatedVisibility( + visible = bleEnabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_searching_24, + title = stringResource(R.string.setting_auto_switch_title), + description = stringResource(R.string.setting_auto_switch_desc), + isChecked = autoConnect, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleAutoConnectEnabled(enabled) + } + } + ) + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d752d8c8..f33d583d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,4 +81,14 @@ Auto App icon credits: @Syntrop2k2 on Telegram https://t.me/Syntrop2k2 + + + Reconnect + Configure auto reconnect + Auto re-connect + Attempted when disconnected unexpectedly + Nearby connection + Use Bluetooth LE + Auto switch + Auto switch to BLE if connection lost \ No newline at end of file From 113eac155b9fa21ce5dd88cb253f52ba4526a5e1 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 11:38:07 +0530 Subject: [PATCH 10/33] refactor: Combine BLE settings to one toggle --- .../sheets/ConnectionSettingsBottomSheet.kt | 26 ++----------------- app/src/main/res/values/strings.xml | 6 ++--- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt index fc6940fb..030fb7e4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt @@ -1,10 +1,5 @@ package com.sameerasw.airsync.presentation.ui.components.sheets -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -43,7 +38,6 @@ fun ConnectionSettingsBottomSheet( val scope = rememberCoroutineScope() val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) - val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) ModalBottomSheet( onDismissRequest = onDismissRequest, @@ -85,28 +79,12 @@ fun ConnectionSettingsBottomSheet( onCheckedChange = { enabled -> scope.launch { dataStoreManager.setBleSyncEnabled(enabled) + dataStoreManager.setBleAutoConnectEnabled(enabled) } } ) - - AnimatedVisibility( - visible = bleEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - IconToggleItem( - iconRes = R.drawable.rounded_bluetooth_searching_24, - title = stringResource(R.string.setting_auto_switch_title), - description = stringResource(R.string.setting_auto_switch_desc), - isChecked = autoConnect, - onCheckedChange = { enabled -> - scope.launch { - dataStoreManager.setBleAutoConnectEnabled(enabled) - } - } - ) - } } } } } + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f33d583d..6e78a719 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,8 +87,6 @@ Configure auto reconnect Auto re-connect Attempted when disconnected unexpectedly - Nearby connection - Use Bluetooth LE - Auto switch - Auto switch to BLE if connection lost + Switch to Nearby + Use Bluetooth LE if connection lost \ No newline at end of file From f69e72803f4692003d2fdd3dbf2a44cef9cdc5a9 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 14:06:44 +0530 Subject: [PATCH 11/33] feat: dynamically update Bluetooth adapter name --- .../airsync/data/ble/BleGattServer.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 d4727d14..f64257e3 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 @@ -74,6 +74,30 @@ class BleGattServer(private val context: Context) { return } + // Set Bluetooth adapter name dynamically based on configured device name to keep BLE matching precise + val customName = try { + runBlocking { dataStoreManager.getDeviceName().first() } + } catch (e: Exception) { + "" + } + val rawName = if (customName.isNotBlank()) customName else com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceName(context) + val baseName = rawName + .replace("AirSync-AirSync-", "") + .replace("AirSync-", "") + .replace("airsync-", "") + .replace("airsync", "") + .trim() + + val bleName = "AirSync-$baseName" + try { + if (adapter.name != bleName) { + adapter.name = bleName + Log.d(TAG, "Updated Bluetooth adapter name to: $bleName") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to set Bluetooth adapter name: ${e.message}") + } + setupGattServer() } From 4a1181475562329ac7a41dfba37016d60856fdb1 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 14:50:23 +0530 Subject: [PATCH 12/33] feat: implement manual BLE disconnect signal and device cleanup during WebSocket closure --- .../airsync/data/ble/BleConnectionManager.kt | 4 ++++ .../sameerasw/airsync/data/ble/BleGattServer.kt | 17 +++++++++++++++++ .../sameerasw/airsync/utils/WebSocketUtil.kt | 16 ++++++++++++++++ 3 files changed, 37 insertions(+) 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 92b66e3b..e7db1bac 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 @@ -74,4 +74,8 @@ class BleConnectionManager(private val context: Context) { fun sendNotification(characteristicUuid: java.util.UUID, data: ByteArray) { bleServer?.sendNotification(characteristicUuid, data) } + + fun disconnectAllConnectedDevices() { + bleServer?.disconnectAllConnectedDevices() + } } 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 f64257e3..29ac9721 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 @@ -243,6 +243,9 @@ class BleGattServer(private val context: Context) { stopHeartbeat() _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false + if (gattServer != null) { + startAdvertising() + } } } } @@ -509,4 +512,18 @@ class BleGattServer(private val context: Context) { char.addDescriptor(configDescriptor) return char } + + fun disconnectAllConnectedDevices() { + Log.d(TAG, "Disconnecting all connected BLE devices manually...") + val devicesCopy = synchronized(connectedDevices) { connectedDevices.toList() } + for (device in devicesCopy) { + try { + gattServer?.cancelConnection(device) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel connection for ${device.address}: ${e.message}") + } + } + isAuthenticated = false + _connectionState.value = BleConnectionState.DISCONNECTED + } } 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 e02f4ecf..c2b8a861 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -655,6 +655,22 @@ object WebSocketUtil { } catch (_: Exception) { } } + + // Send manual disconnect signal over BLE before disconnecting BLE client + try { + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() + if (ble != null && ble.isAuthenticated) { + Log.d(TAG, "Sending manual disconnect signal over BLE before disconnecting") + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, "remote|manual_disconnect") + + CoroutineScope(Dispatchers.IO).launch { + delay(300) + ble.disconnectAllConnectedDevices() + } + } + } catch (e: Exception) { + Log.e(TAG, "Error sending manual disconnect signal over BLE: ${e.message}") + } } webSocket?.close(1000, "Manual disconnection") From 26a901c7c2560d942763aa073458dfe0bbb37723 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 16:12:10 +0530 Subject: [PATCH 13/33] feat: add toggle checks for discovery BLE authentication requirements --- .../airsync/data/ble/BleGattServer.kt | 30 ++++++++++++++++++- .../airsync/utils/UDPDiscoveryManager.kt | 11 ++++++- 2 files changed, 39 insertions(+), 2 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 29ac9721..5264a8f2 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 @@ -74,6 +74,16 @@ class BleGattServer(private val context: Context) { return } + val isEnabled = try { + runBlocking { dataStoreManager.getBleSyncEnabled().first() } + } catch (e: Exception) { + false + } + if (!isEnabled) { + Log.d(TAG, "BLE Sync is disabled in settings, skipping start") + return + } + // Set Bluetooth adapter name dynamically based on configured device name to keep BLE matching precise val customName = try { runBlocking { dataStoreManager.getDeviceName().first() } @@ -244,7 +254,17 @@ class BleGattServer(private val context: Context) { _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false if (gattServer != null) { - startAdvertising() + val isEnabled = try { + runBlocking { dataStoreManager.getBleSyncEnabled().first() } + } catch (e: Exception) { + false + } + if (isEnabled) { + startAdvertising() + } else { + Log.d(TAG, "BLE Sync is disabled, stopping server") + stop() + } } } } @@ -268,6 +288,14 @@ class BleGattServer(private val context: Context) { override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}") + if (characteristic.uuid != BleConstants.CHAR_AUTH_TOKEN && !isAuthenticated) { + Log.w(TAG, "Blocked unauthorized write request to ${characteristic.uuid} from ${device.address}") + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_WRITE_NOT_PERMITTED, offset, null) + } + return + } + when (characteristic.uuid) { BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) 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 73a240b4..b1fb2fb5 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -119,6 +119,11 @@ object UDPDiscoveryManager { } fun burstBroadcast(context: Context, durationMs: Long = 30000) { + if (!isDiscoveryEnabled) { + Log.d(TAG, "Discovery disabled, skipping burst broadcast") + return + } + Log.d(TAG, "Starting burst broadcast for ${durationMs}ms") burstJob?.cancel() burstJob = CoroutineScope(Dispatchers.IO).launch { @@ -346,6 +351,8 @@ object UDPDiscoveryManager { } private fun broadcastPresence(context: Context) { + if (!isDiscoveryEnabled) return + val allIps = getAllIpAddresses() if (allIps.isEmpty()) { return @@ -427,6 +434,8 @@ object UDPDiscoveryManager { } private fun broadcastGoodbye(context: Context) { + if (!isDiscoveryEnabled) return + val allIps = getAllIpAddresses() if (allIps.isEmpty()) return @@ -447,7 +456,7 @@ object UDPDiscoveryManager { val packet = DatagramPacket( data, data.size, - InetAddress.getByName("255.55.255.255"), + InetAddress.getByName("255.255.255.255"), BROADCAST_PORT ) DatagramSocket(0, InetAddress.getByName(bindIp)).use { sender -> From 95beca53630c8f0f7a2708a6361fd0efc38abb9f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 17:23:17 +0530 Subject: [PATCH 14/33] refactor: standardize UI card components with updated iconography, styling, and improved layout consistency --- .../ui/components/RoundedCardContainer.kt | 9 +- .../ui/components/SettingsView.kt | 181 +++++------ .../components/cards/ConnectionStatusCard.kt | 278 ++++++++--------- .../ui/components/cards/DefaultTabCard.kt | 15 +- .../ui/components/cards/DeveloperModeCard.kt | 289 +++++++++--------- .../ui/components/cards/DeviceInfoCard.kt | 36 ++- .../components/cards/ExpandNetworkingCard.kt | 66 ++-- .../ui/components/cards/IconToggleItem.kt | 132 ++++---- .../cards/LastConnectedDeviceCard.kt | 235 ++++++-------- .../components/cards/ManualConnectionCard.kt | 177 ++++++----- .../ui/components/cards/MediaSyncCard.kt | 87 ++---- .../components/cards/NotificationSyncCard.kt | 80 ++--- .../ui/components/cards/PermissionsCard.kt | 68 +++-- .../cards/QuickSettingsTilesCard.kt | 9 +- .../ui/components/cards/SendNowPlayingCard.kt | 64 ---- .../ui/components/cards/SmartspacerCard.kt | 59 +--- .../ui/components/cards/SyncFeaturesCard.kt | 130 ++------ .../ui/composables/WelcomeScreen.kt | 8 +- .../ui/screens/AirSyncMainScreen.kt | 24 +- app/src/main/res/drawable/quick_share.xml | 27 ++ .../res/drawable/rounded_bug_report_24.xml | 20 ++ .../res/drawable/rounded_dark_mode_24.xml | 20 ++ .../res/drawable/rounded_extension_24.xml | 20 ++ .../main/res/drawable/rounded_history_24.xml | 20 ++ .../res/drawable/rounded_music_cast_24.xml | 20 ++ .../res/drawable/rounded_shield_toggle_24.xml | 20 ++ .../res/drawable/rounded_smart_display_24.xml | 20 ++ 27 files changed, 945 insertions(+), 1169 deletions(-) delete mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt create mode 100644 app/src/main/res/drawable/quick_share.xml create mode 100644 app/src/main/res/drawable/rounded_bug_report_24.xml create mode 100644 app/src/main/res/drawable/rounded_dark_mode_24.xml create mode 100644 app/src/main/res/drawable/rounded_extension_24.xml create mode 100644 app/src/main/res/drawable/rounded_history_24.xml create mode 100644 app/src/main/res/drawable/rounded_music_cast_24.xml create mode 100644 app/src/main/res/drawable/rounded_shield_toggle_24.xml create mode 100644 app/src/main/res/drawable/rounded_smart_display_24.xml diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt index 01f41696..d9855a03 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt @@ -1,12 +1,15 @@ package com.sameerasw.airsync.presentation.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -17,6 +20,7 @@ import androidx.compose.ui.unit.dp * @param modifier Modifier to apply to the container * @param spacing Vertical spacing between child cards (default: 2.dp) * @param cornerRadius Corner radius for the entire container (default: 24.dp) + * @param containerColor Background color for the container * @param content The content to be placed inside the container */ @Composable @@ -24,13 +28,16 @@ fun RoundedCardContainer( modifier: Modifier = Modifier, spacing: Dp = 2.dp, cornerRadius: Dp = 24.dp, + containerColor: Color = Color.Transparent, content: @Composable ColumnScope.() -> Unit ) { Column( modifier = modifier - .clip(RoundedCornerShape(cornerRadius)), + .clip(RoundedCornerShape(cornerRadius)) + .background(containerColor), verticalArrangement = Arrangement.spacedBy(spacing), content = content ) } + 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 eff639d3..2af74a68 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 @@ -29,6 +29,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -37,6 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard @@ -48,7 +51,7 @@ import com.sameerasw.airsync.presentation.ui.components.cards.MediaSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.NotificationSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.PermissionsCard import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTilesCard -import com.sameerasw.airsync.presentation.ui.components.cards.SendNowPlayingCard +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.HapticUtil @@ -116,46 +119,16 @@ fun SettingsView( PermissionsCard(missingPermissionsCount = uiState.missingPermissions.size) // Help and guides card - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - HapticUtil.performClick(haptics) - onShowHelp() - }, - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_help_guides), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_help_guides), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - - Icon( - painter = androidx.compose.ui.res.painterResource(id = com.sameerasw.airsync.R.drawable.rounded_keyboard_arrow_right_24), - contentDescription = "Open help", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurface - ) + com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem( + iconRes = com.sameerasw.airsync.R.drawable.rounded_info_24, + title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_help_guides), + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_help_guides), + showToggle = false, + onClick = { + HapticUtil.performClick(haptics) + onShowHelp() } - } + ) QuickSettingsTilesCard( isConnectionTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( @@ -165,10 +138,6 @@ fun SettingsView( isClipboardTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( context, com.sameerasw.airsync.service.ClipboardTileService::class.java - ), - isQuickShareTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( - context, - ) ) } @@ -182,13 +151,9 @@ fun SettingsView( onDefaultTabChange = { tab -> viewModel.setDefaultTab(tab) } ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isBlurSettingEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setUseBlurEnabled(enabled, context) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_use_blur), - subtitle = when { + description = when { com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice() -> androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_blur_disabled_samsung) @@ -197,25 +162,32 @@ fun SettingsView( else -> androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_use_blur) }, + iconRes = R.drawable.rounded_blur_on_24, + isChecked = uiState.isBlurSettingEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setUseBlurEnabled(enabled, context) + }, enabled = !com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice() ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isPitchBlackThemeEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setPitchBlackThemeEnabled(enabled) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_pitch_black_theme), - subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_pitch_black_theme) + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_pitch_black_theme), + iconRes = R.drawable.rounded_dark_mode_24, + isChecked = uiState.isPitchBlackThemeEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setPitchBlackThemeEnabled(enabled) + } ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isSentryReportingEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setSentryReportingEnabled(enabled) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_error_reporting), - subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_error_reporting) + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_error_reporting), + iconRes = R.drawable.rounded_bug_report_24, + isChecked = uiState.isSentryReportingEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setSentryReportingEnabled(enabled) + } ) } } @@ -261,13 +233,14 @@ fun SettingsView( } ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isQuickShareEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setQuickShareEnabled(context, enabled) - }, + IconToggleItem( title = "Quick Share", - subtitle = "Allow receiving files from nearby devices" + description = "Allow receiving files from nearby devices", + iconRes = R.drawable.quick_share, + isChecked = uiState.isQuickShareEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setQuickShareEnabled(context, enabled) + } ) } } @@ -292,51 +265,45 @@ fun SettingsView( } if (isEssentialsInstalled) { - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isEssentialsConnectionEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setEssentialsConnectionEnabled(enabled) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials), - subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials_summary) + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials_summary), + iconRes = R.drawable.essentials_icon, + isChecked = uiState.isEssentialsConnectionEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setEssentialsConnectionEnabled(enabled) + } ) } else { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - androidx.compose.material3.ListItem( - colors = androidx.compose.material3.ListItemDefaults.colors( - containerColor = androidx.compose.ui.graphics.Color.Transparent - ), - headlineContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials)) }, - supportingContent = { - Text( - androidx.compose.ui.res.stringResource( - com.sameerasw.airsync.R.string.download_essentials_summary - ) + ListItem( + colors = ListItemDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent + ), + headlineContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials)) }, + supportingContent = { + Text( + androidx.compose.ui.res.stringResource( + com.sameerasw.airsync.R.string.download_essentials_summary ) - }, - trailingContent = { - Button( - onClick = { - val intent = android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://github.com/sameerasw/essentials/releases/latest") - ) - intent.flags = - android.content.Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - } - ) { - Text("Download") + ) + }, + trailingContent = { + Button( + onClick = { + HapticUtil.performClick(haptics) + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("https://github.com/sameerasw/essentials/releases/latest") + ) + intent.flags = + android.content.Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) } + ) { + Text("Download") } - ) - } + } + ) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index 54f03e3b..eab5a685 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt @@ -2,16 +2,12 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -25,23 +21,16 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice import com.sameerasw.airsync.domain.model.UiState -import com.sameerasw.airsync.presentation.ui.components.RotatingAppIcon import com.sameerasw.airsync.presentation.ui.components.SlowlyRotatingAppIcon import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @@ -55,189 +44,160 @@ fun ConnectionStatusCard( connectedDevice: ConnectedDevice? = null, lastConnected: Boolean, uiState: UiState, + modifier: Modifier = Modifier ) { val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current - // Determine gradient color - val gradientColor = when { - isConnected -> MaterialTheme.colorScheme.primary - isConnecting -> Color(0xFFFFC107) // Yellow - else -> Color(0xFFF44336) // Red - } - Card( - modifier = Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = if (isConnected) 160.dp else 50.dp) - .animateContentSize(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { Column( modifier = Modifier - .fillMaxSize() -// .background( -// brush = Brush.linearGradient( -// colors = listOf( -// gradientColor.copy(alpha = 0.3f), -// Color.Transparent -// ), -// start = Offset(0f, 1f), -// end = Offset.Infinite -// ) -// ) + .fillMaxWidth() + .defaultMinSize(minHeight = if (isConnected) 160.dp else 50.dp) + .animateContentSize() .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // 1) Device image at the top (only when connected) - if (isConnected) { - val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Connected Mac preview", - modifier = Modifier - .fillMaxWidth(0.75f), - contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + // 1) Device image at the top (only when connected) + if (isConnected) { + val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) + Image( + painter = painterResource(id = previewRes), + contentDescription = "Connected Mac preview", + modifier = Modifier.fillMaxWidth(0.75f), + contentScale = ContentScale.Fit, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } + + // 2) Device info block (when connected) + if (isConnected && connectedDevice != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${connectedDevice.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) ) - } - // 2) Device info block (when connected) - if (isConnected && connectedDevice != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Surface( + shape = RoundedCornerShape(8.dp), + color = if (connectedDevice.isPlus) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(start = 16.dp) ) { Text( - "${connectedDevice.name}", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.weight(1f) + text = if (connectedDevice.isPlus) "PLUS" else "FREE", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (connectedDevice.isPlus) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant ) + } + } - Card( - colors = CardDefaults.cardColors( - containerColor = if (connectedDevice.isPlus) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant - ), - modifier = Modifier.padding(start = 16.dp) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val ips = uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } + ips.forEach { ip -> + val isActive = ip == uiState.activeIp + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.animateContentSize() ) { Text( - text = if (connectedDevice.isPlus) "PLUS" else "FREE", - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = if (connectedDevice.isPlus) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurfaceVariant + text = "$ip:${connectedDevice.port}", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant ) } } + } + } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val ips = - uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } - ips.forEach { ip -> - val isActive = ip == uiState.activeIp - Surface( - shape = RoundedCornerShape(12.dp), - color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.animateContentSize() - ) { - Text( - text = "$ip:${connectedDevice.port}", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + // 3) Connection status row last + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = if (isConnected) 0.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val statusText = when { + isConnecting -> "Connecting..." + isConnected -> "Syncing" + else -> "Disconnected" } - // 3) Connection status row last - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = if (isConnected) 0.dp else 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val statusText = when { - isConnecting -> "Connecting..." - isConnected -> "Syncing" - else -> "Disconnected" - } + if (isConnecting) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() } + } - if (isConnecting) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() } + if (isConnected) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SlowlyRotatingAppIcon( + modifier = Modifier.size(54.dp) + ) } + } else if (!isConnecting) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), + contentDescription = "Disconnected", + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.error + ) + } - if (isConnected) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - SlowlyRotatingAppIcon( - modifier = Modifier - .size(54.dp) - ) - } -// Icon( -// painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_24), -// contentDescription = "Connected", -// modifier = Modifier.padding(end = 8.dp), -// tint = MaterialTheme.colorScheme.primary -// ) + Text( + text = statusText, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) - } else if (!isConnecting) { + if (isConnected) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onDisconnect() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.height(48.dp) + ) { Icon( painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = "Disconnected", - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.error + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = "Disconnect", + style = MaterialTheme.typography.labelLarge, + maxLines = 1 ) - } - - Text( - text = statusText, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - - if (isConnected) { - - Button( - onClick = { - HapticUtil.performClick(haptics) - onDisconnect() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ), - modifier = Modifier - .height(48.dp) - ) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.size(6.dp)) - Text( - text = "Disconnect", - style = MaterialTheme.typography.labelLarge, - maxLines = 1 - ) - } } } } } - - +} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt index 386b63fb..03f62102 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Gamepad -import androidx.compose.material.icons.filled.Phonelink import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -33,19 +32,22 @@ import com.sameerasw.airsync.utils.HapticUtil @Composable fun DefaultTabCard( currentDefaultTab: String, - onDefaultTabChange: (String) -> Unit + onDefaultTabChange: (String) -> Unit, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { Column( - modifier = Modifier.padding(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) ) { Text( text = "Default tab", @@ -66,7 +68,7 @@ fun DefaultTabCard( ) { TabOption( title = "Connect", - iconRes = R.drawable.ic_launcher_monochrome, + iconRes = R.drawable.rounded_devices_24, isSelected = currentDefaultTab == "connect", onClick = { HapticUtil.performClick(haptics) @@ -105,6 +107,7 @@ fun DefaultTabCard( } } + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun TabOption( 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 36ae30ba..a7494d83 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 @@ -13,14 +13,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.background import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -28,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.utils.HapticUtil @Composable @@ -41,203 +39,194 @@ fun DeveloperModeCard( onExportData: () -> Unit, onImportData: () -> Unit, onResetOnboarding: () -> Unit, - // Icon Sync Parameters isIconSyncLoading: Boolean, iconSyncMessage: String, onManualSyncIcons: () -> Unit, onClearIconSyncMessage: () -> Unit, - isConnected: Boolean + isConnected: Boolean, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Developer Mode", style = MaterialTheme.typography.titleMedium) - Switch( - checked = isDeveloperMode, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleDeveloperMode(enabled) - } - ) - } + Column(modifier = Modifier.fillMaxWidth()) { + IconToggleItem( + iconRes = R.drawable.rounded_troubleshoot_24, + title = "Developer Mode", + isChecked = isDeveloperMode, + onCheckedChange = onToggleDeveloperMode + ) - if (isDeveloperMode) { - Spacer(modifier = Modifier.height(16.dp)) + if (isDeveloperMode) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text( "Test Functions", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendDeviceInfo() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Device Info") + } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onSendDeviceInfo() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Send Device Info") - } + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendNotification() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Test Notification") + } + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendDeviceStatus() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Device Status") + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { HapticUtil.performClick(haptics) - onSendNotification() + onExportData() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), enabled = !isLoading ) { - Text("Send Test Notification") + Text("Export Data") } Button( onClick = { HapticUtil.performClick(haptics) - onSendDeviceStatus() + onImportData() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), enabled = !isLoading ) { - Text("Send Device Status") + Text("Import Data") } + } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onExportData() - }, - modifier = Modifier.weight(1f), - enabled = !isLoading - ) { - Text("Export Data") - } + Button( + onClick = { + HapticUtil.performClick(haptics) + onResetOnboarding() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Reset Onboarding") + } - Button( - onClick = { - HapticUtil.performClick(haptics) - onImportData() - }, - modifier = Modifier.weight(1f), - enabled = !isLoading - ) { - Text("Import Data") - } - } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Icons", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) - Button( - onClick = { - HapticUtil.performClick(haptics) - onResetOnboarding() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Reset Onboarding") + Button( + onClick = { + HapticUtil.performClick(haptics) + onManualSyncIcons() + }, + modifier = Modifier.fillMaxWidth(), + enabled = isConnected && !isIconSyncLoading + ) { + if (isIconSyncLoading) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) } + Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons") + } - // Consolidated Icon Sync Section - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Icons", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) - - Button( - onClick = { - HapticUtil.performClick(haptics) - onManualSyncIcons() - }, + AnimatedVisibility( + visible = iconSyncMessage.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Card( modifier = Modifier.fillMaxWidth(), - enabled = isConnected && !isIconSyncLoading - ) { - if (isIconSyncLoading) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons") - } - - AnimatedVisibility( - visible = iconSyncMessage.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = if (iconSyncMessage.contains("Successfully")) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer + ) ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = if (iconSyncMessage.contains("Successfully")) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.errorContainer - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = iconSyncMessage, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - color = if (iconSyncMessage.contains("Successfully")) - MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onErrorContainer - ) - TextButton(onClick = { - HapticUtil.performClick(haptics) - onClearIconSyncMessage() - }) { - Text("Dismiss", style = MaterialTheme.typography.labelMedium) - } + Text( + text = iconSyncMessage, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = if (iconSyncMessage.contains("Successfully")) + MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onErrorContainer + ) + TextButton(onClick = { + HapticUtil.performClick(haptics) + onClearIconSyncMessage() + }) { + Text("Dismiss", style = MaterialTheme.typography.labelMedium) } } } - // Sentry section - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Crash Reporting", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) + } - Button( - onClick = { - HapticUtil.performClick(haptics) - throw RuntimeException("Test Crash from Developer Options") - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Simulate Crash") - } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Crash Reporting", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + + Button( + onClick = { + HapticUtil.performClick(haptics) + throw RuntimeException("Test Crash from Developer Options") + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Simulate Crash") } } } } +} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt index 315e7766..a4523d51 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt @@ -19,27 +19,33 @@ fun DeviceInfoCard( deviceName: String, localIp: String, onDeviceNameChange: (String) -> Unit, + modifier: Modifier = Modifier ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { - Column(modifier = Modifier.padding(16.dp)) { - Text("My Android", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) - Text("Local IP: $localIp", style = MaterialTheme.typography.bodyMedium) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text("My Android", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text("Local IP: $localIp", style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = deviceName, - onValueChange = onDeviceNameChange, - label = { Text("Device Name") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = deviceName, + onValueChange = onDeviceNameChange, + label = { Text("Device Name") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) } } +} + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt index ef387993..710a6db7 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt @@ -1,15 +1,6 @@ package com.sameerasw.airsync.presentation.ui.components.cards import android.content.Context -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -18,14 +9,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.data.local.DataStoreManager -import com.sameerasw.airsync.utils.HapticUtil import kotlinx.coroutines.launch @Composable -fun ExpandNetworkingCard(context: Context) { +fun ExpandNetworkingCard( + context: Context, + modifier: Modifier = Modifier +) { val ds = remember { DataStoreManager(context) } val scope = rememberCoroutineScope() val enabledFlow = ds.getExpandNetworkingEnabled() @@ -37,42 +29,18 @@ fun ExpandNetworkingCard(context: Context) { } } - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 0.dp, horizontal = 0.dp), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text("Expand networking", style = MaterialTheme.typography.titleMedium) - Text( - "Allow connecting to device outside the local network", - modifier = Modifier.padding(top = 4.dp), - style = MaterialTheme.typography.bodySmall - ) + IconToggleItem( + modifier = modifier, + iconRes = R.drawable.rounded_android_wifi_3_bar_24, + title = "Expand networking", + description = "Allow connecting to device outside the local network", + isChecked = enabled, + onCheckedChange = { value -> + enabled = value + scope.launch { + ds.setExpandNetworkingEnabled(value) } - val haptics = LocalHapticFeedback.current - Switch( - checked = enabled, - onCheckedChange = { - enabled = it - if (it) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - scope.launch { - ds.setExpandNetworkingEnabled(it) - } - } - ) } - } + ) } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt index fd3e4fba..86de817a 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt @@ -1,16 +1,14 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -19,78 +17,86 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.utils.HapticUtil @Composable fun IconToggleItem( - iconRes: Int, title: String, modifier: Modifier = Modifier, + iconRes: Int? = null, description: String? = null, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, + isChecked: Boolean = false, + onCheckedChange: ((Boolean) -> Unit)? = null, enabled: Boolean = true, onDisabledClick: (() -> Unit)? = null, - showToggle: Boolean = true + showToggle: Boolean = true, + onClick: (() -> Unit)? = null, + trailingIcon: Int? = null ) { val haptics = LocalHapticFeedback.current - Row( - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .clickable(enabled = !showToggle && enabled) { - if (enabled) { - HapticUtil.performClick(haptics) - onCheckedChange(!isChecked) - } else if (onDisabledClick != null) { - HapticUtil.performClick(haptics) - onDisabledClick() - } - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - Icon( - painter = painterResource(id = iconRes), - contentDescription = title, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright ) - Spacer(modifier = Modifier.size(2.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = enabled || onDisabledClick != null, + onClick = { + if (enabled) { + HapticUtil.performClick(haptics) + if (onClick != null) { + onClick() + } else if (onCheckedChange != null && showToggle) { + onCheckedChange(!isChecked) + } + } else if (onDisabledClick != null) { + HapticUtil.performClick(haptics) + onDisabledClick() + } + } + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (iconRes != null) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } - if (description != null) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = description, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + modifier = Modifier.padding(top = 2.dp) + ) + } } - } else { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.onSurface - ) - } - if (showToggle) { - Box { + if (showToggle && onCheckedChange != null) { Switch( checked = if (enabled) isChecked else false, onCheckedChange = { checked -> @@ -101,15 +107,13 @@ fun IconToggleItem( }, enabled = enabled ) - - if (!enabled && onDisabledClick != null) { - Box(modifier = Modifier - .matchParentSize() - .clickable { - HapticUtil.performClick(haptics) - onDisabledClick() - }) - } + } else if (onClick != null && !showToggle) { + Icon( + painter = painterResource(id = trailingIcon ?: R.drawable.rounded_keyboard_arrow_right_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index a797a9ac..0eebfbe5 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -1,7 +1,6 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,12 +10,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,167 +42,124 @@ fun LastConnectedDeviceCard( isAutoReconnectEnabled: Boolean, onToggleAutoReconnect: (Boolean) -> Unit, onQuickConnect: () -> Unit, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current + var showBottomSheet by remember { mutableStateOf(false) } Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "Last Connected Device", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically ) { - Text( - "Last Connected Device", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary + val previewRes = DevicePreviewResolver.getPreviewRes(device) + Image( + painter = painterResource(id = previewRes), + contentDescription = "Connected Mac preview", + modifier = Modifier.fillMaxWidth(0.45f), + contentScale = ContentScale.Fit, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) ) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - val previewRes = DevicePreviewResolver.getPreviewRes(device) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Connected Mac preview", - modifier = Modifier - .fillMaxWidth(0.45f), - contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "${device.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column{ - - Text( - "${device.name}", - style = MaterialTheme.typography.headlineSmall - ) - - val lastConnectedTime = remember(device.lastConnected) { - val currentTime = System.currentTimeMillis() - val diffMinutes = (currentTime - device.lastConnected) / (1000 * 60) - when { - diffMinutes < 1 -> "Just now" - diffMinutes < 60 -> "${diffMinutes}m ago" - diffMinutes < 1440 -> "${diffMinutes / 60}h ago" - else -> "${diffMinutes / 1440}d ago" - } + val lastConnectedTime = remember(device.lastConnected) { + val currentTime = System.currentTimeMillis() + val diffMinutes = (currentTime - device.lastConnected) / (1000 * 60) + when { + diffMinutes < 1 -> "Just now" + diffMinutes < 60 -> "${diffMinutes}m ago" + diffMinutes < 1440 -> "${diffMinutes / 60}h ago" + else -> "${diffMinutes / 1440}d ago" } - Text( - "Last seen $lastConnectedTime", - style = MaterialTheme.typography.bodyMedium - ) - - } - - // Display status badge - PLUS or FREE - Card( - colors = CardDefaults.cardColors( - containerColor = if (device.isPlus) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant - ), - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = if (device.isPlus) "PLUS" else "FREE", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - color = if (device.isPlus) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurfaceVariant - ) } + Text( + "Last seen $lastConnectedTime", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } -// device.deviceType?.let { type -> -// Text("Type: $type", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) -// } - - Button( - onClick = { - HapticUtil.performClick(haptics) - onQuickConnect() - }, - modifier = Modifier - .fillMaxWidth() - .requiredHeight(65.dp) - .padding(top = 16.dp), + Surface( + shape = RoundedCornerShape(8.dp), + color = if (device.isPlus) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(start = 8.dp) ) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), - contentDescription = "Quick connect", - modifier = Modifier.padding(end = 12.dp), -// tint = MaterialTheme.colorScheme.primary + Text( + text = if (device.isPlus) "PLUS" else "FREE", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (device.isPlus) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant ) - Text("Quick Connect") } - - } - var showBottomSheet by remember { mutableStateOf(false) } - // Auto-reconnect & Bluetooth settings card - Card( + Button( + onClick = { + HapticUtil.performClick(haptics) + onQuickConnect() + }, modifier = Modifier .fillMaxWidth() - .clickable { - HapticUtil.performClick(haptics) - showBottomSheet = true - }, - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + .requiredHeight(48.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_compare_arrows_24), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Column { - Text( - text = stringResource(R.string.bluetooth_settings_card_title), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.bluetooth_settings_card_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Icon( - painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24), - contentDescription = "Configure settings", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), + contentDescription = "Quick connect", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Quick Connect") } + } + + + IconToggleItem( + iconRes = R.drawable.rounded_compare_arrows_24, + title = stringResource(R.string.bluetooth_settings_card_title), + description = stringResource(R.string.bluetooth_settings_card_desc), + showToggle = false, + onClick = { + HapticUtil.performClick(haptics) + showBottomSheet = true + } + ) if (showBottomSheet) { ConnectionSettingsBottomSheet( @@ -211,5 +168,5 @@ fun LastConnectedDeviceCard( onDismissRequest = { showBottomSheet = false } ) } - } +} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt index 2f32ca0b..2ec23551 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt @@ -1,7 +1,6 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -43,111 +43,110 @@ fun ManualConnectionCard( onIsPlusChange: (Boolean) -> Unit, onSymmetricKeyChange: (String) -> Unit, onConnect: () -> Unit, - onQrScanClick: (() -> Unit)? = null + onQrScanClick: (() -> Unit)? = null, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current var expanded by remember { mutableStateOf(false) } Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - HapticUtil.performLightTick(haptics) - expanded = !expanded - } - ) { - Text("Manual Connection", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.weight(1f)) - Icon( - painter = painterResource( - if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 - ), - contentDescription = if (expanded) "Collapse" else "Expand" - ) - } + Column(modifier = Modifier.fillMaxWidth()) { + IconToggleItem( + iconRes = R.drawable.rounded_devices_24, + title = "Manual Connection", + description = if (expanded) "Hide connection details" else "Enter connection details manually", + showToggle = false, + onClick = { + HapticUtil.performLightTick(haptics) + expanded = !expanded + }, + trailingIcon = if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 + ) - AnimatedVisibility(visible = expanded) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 16.dp) - ) { - // QR Scanner button - if (onQrScanClick != null) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onQrScanClick() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), - contentDescription = "Scan QR Code", - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp) - ) - Text("Scan QR Code") - } - } - OutlinedTextField( - value = uiState.ipAddress, - onValueChange = onIpChange, - label = { Text("IP Address") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.port, - onValueChange = onPortChange, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.manualPcName, - onValueChange = onPcNameChange, - label = { Text("PC Name (Optional)") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - OutlinedTextField( - value = uiState.symmetricKey ?: "", - onValueChange = onSymmetricKeyChange, - label = { Text("Encryption Key") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("AirSync+") - Spacer(Modifier.weight(1f)) - Switch( - checked = uiState.manualIsPlus, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onIsPlusChange(enabled) - } - ) - } + AnimatedVisibility(visible = expanded) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (onQrScanClick != null) { Button( onClick = { HapticUtil.performClick(haptics) - onConnect() + onQrScanClick() }, modifier = Modifier.fillMaxWidth(), ) { - Text("Connect") + Icon( + painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), + contentDescription = "Scan QR Code", + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp) + ) + Text("Scan QR Code") } } + OutlinedTextField( + value = uiState.ipAddress, + onValueChange = onIpChange, + label = { Text("IP Address") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.port, + onValueChange = onPortChange, + label = { Text("Port") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.manualPcName, + onValueChange = onPcNameChange, + label = { Text("PC Name (Optional)") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = uiState.symmetricKey ?: "", + onValueChange = onSymmetricKeyChange, + label = { Text("Encryption Key") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("AirSync+", color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.weight(1f)) + Switch( + checked = uiState.manualIsPlus, + onCheckedChange = { enabled -> + if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( + haptics + ) + onIsPlusChange(enabled) + } + ) + } + Button( + onClick = { + HapticUtil.performClick(haptics) + onConnect() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Connect") + } } } } } +} + 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 f7f6da44..a5c42da3 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 @@ -2,85 +2,34 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun MediaSyncCard( isSendNowPlayingEnabled: Boolean, onToggleSendNowPlaying: (Boolean) -> Unit, isMacMediaControlsEnabled: Boolean, - onToggleMacMediaControls: (Boolean) -> Unit + onToggleMacMediaControls: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + IconToggleItem( + iconRes = R.drawable.rounded_music_cast_24, + title = "Send now playing", + description = "Share media playback details with desktop", + isChecked = isSendNowPlayingEnabled, + onCheckedChange = onToggleSendNowPlaying + ) + IconToggleItem( + iconRes = R.drawable.rounded_smart_display_24, + title = "Show Mac Media Controls", + description = "Show media controls when Mac is playing music", + isChecked = isMacMediaControlsEnabled, + onCheckedChange = onToggleMacMediaControls ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - // Send Now Playing Row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Send now playing", style = MaterialTheme.typography.titleMedium) - Text( - "Share media playback details with desktop", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isSendNowPlayingEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics) - onToggleSendNowPlaying(enabled) - } - ) - } - - // Mac Media Controls Row - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Show Mac Media Controls", style = MaterialTheme.typography.titleMedium) - Text( - "Show media controls when Mac is playing music", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isMacMediaControlsEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics) - onToggleMacMediaControls(enabled) - } - ) - } - } } } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt index 35f87132..af70b4f8 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt @@ -1,75 +1,31 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun NotificationSyncCard( isNotificationEnabled: Boolean, isNotificationSyncEnabled: Boolean, onToggleSync: (Boolean) -> Unit, - onGrantPermissions: () -> Unit + onGrantPermissions: () -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Notification Sync", style = MaterialTheme.typography.titleMedium) - } - - Switch( - checked = isNotificationSyncEnabled && isNotificationEnabled, - onCheckedChange = { enabled -> - if (isNotificationEnabled) { - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleSync(enabled) - } else { - HapticUtil.performClick(haptics) - onGrantPermissions() - } - }, - enabled = isNotificationEnabled - ) + IconToggleItem( + modifier = modifier, + iconRes = R.drawable.rounded_notifications_active_24, + title = "Notification Sync", + description = if (!isNotificationEnabled) "❌ Notification access required" else null, + isChecked = isNotificationSyncEnabled && isNotificationEnabled, + onCheckedChange = { enabled -> + if (isNotificationEnabled) { + onToggleSync(enabled) + } else { + onGrantPermissions() } - - if (!isNotificationEnabled) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "❌ Notification access required", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - } - } + }, + enabled = isNotificationEnabled, + onDisabledClick = onGrantPermissions + ) } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt index 231dd38d..afd5b699 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt @@ -26,53 +26,69 @@ import com.sameerasw.airsync.utils.HapticUtil @Composable fun PermissionsCard( - missingPermissionsCount: Int = 0 + missingPermissionsCount: Int = 0, + modifier: Modifier = Modifier ) { val context = LocalContext.current val haptics = LocalHapticFeedback.current + val hasMissing = missingPermissionsCount > 0 + Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - HapticUtil.performClick(haptics) - val intent = Intent(context, PermissionsActivity::class.java) - context.startActivity(intent) - }, + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = if (missingPermissionsCount > 0) + containerColor = if (hasMissing) MaterialTheme.colorScheme.errorContainer else - MaterialTheme.colorScheme.surfaceContainerHighest + MaterialTheme.colorScheme.surfaceBright ) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .clickable { + HapticUtil.performClick(haptics) + val intent = Intent(context, PermissionsActivity::class.java) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + if (hasMissing) { + Icon( + painter = painterResource(id = R.drawable.rounded_shield_toggle_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.error + ) + } else { + Icon( + painter = painterResource(id = R.drawable.rounded_task_alt_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { Text( "Permissions", - style = MaterialTheme.typography.titleMedium, - color = if (missingPermissionsCount > 0) - MaterialTheme.colorScheme.onErrorContainer - else - MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.bodyLarge, + color = if (hasMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface ) Text( - if (missingPermissionsCount > 0) + if (hasMissing) "$missingPermissionsCount missing" else "All permissions granted", - style = MaterialTheme.typography.bodySmall, - color = if (missingPermissionsCount > 0) - MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f) - else - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + style = MaterialTheme.typography.bodyMedium, + color = if (hasMissing) MaterialTheme.colorScheme.error.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -80,12 +96,10 @@ fun PermissionsCard( painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24), contentDescription = "Open permissions", modifier = Modifier.size(24.dp), - tint = if (missingPermissionsCount > 0) - MaterialTheme.colorScheme.onErrorContainer - else - MaterialTheme.colorScheme.onSurface + tint = if (hasMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant ) } } } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt index a94f9fc5..26d0c5d6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -31,13 +32,13 @@ import com.sameerasw.airsync.utils.QuickSettingsUtil fun QuickSettingsTilesCard( isConnectionTileAdded: Boolean, isClipboardTileAdded: Boolean, - isQuickShareTileAdded: Boolean + modifier: Modifier = Modifier ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { Row( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt deleted file mode 100644 index 2f0205c8..00000000 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.sameerasw.airsync.presentation.ui.components.cards - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.utils.HapticUtil - -@Composable -fun SendNowPlayingCard( - isSendNowPlayingEnabled: Boolean, - onToggleSendNowPlaying: (Boolean) -> Unit, - title: String = "Send now playing", - subtitle: String = "Share media playback details with desktop", - enabled: Boolean = true -) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isSendNowPlayingEnabled, - enabled = enabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleSendNowPlaying(enabled) - } - ) - } - } -} - diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt index d4027eec..8e83052e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt @@ -1,60 +1,23 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun SmartspacerCard( isSmartspacerShowWhenDisconnected: Boolean, onToggleSmartspacerShowWhenDisconnected: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Smartspacer", style = MaterialTheme.typography.titleMedium) - Text( - "Show Smartspacer when disconnected", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isSmartspacerShowWhenDisconnected, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleSmartspacerShowWhenDisconnected(enabled) - } - ) - } - } + IconToggleItem( + modifier = modifier, + iconRes = R.drawable.rounded_asterisk_24, + title = "Smartspacer", + description = "Show Smartspacer when disconnected", + isChecked = isSmartspacerShowWhenDisconnected, + onCheckedChange = onToggleSmartspacerShowWhenDisconnected + ) } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt index 612de356..f4e640a7 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt @@ -2,20 +2,10 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun ClipboardFeaturesCard( @@ -24,103 +14,37 @@ fun ClipboardFeaturesCard( // Continue Browsing props isContinueBrowsingEnabled: Boolean, onToggleContinueBrowsing: (Boolean) -> Unit, - // New: control the UI enabled state and subtitle for Continue Browsing + // Control the UI enabled state and subtitle for Continue Browsing isContinueBrowsingToggleEnabled: Boolean, continueBrowsingSubtitle: String, - // New: Keep previous link props + // Keep previous link props isKeepPreviousLinkEnabled: Boolean, onToggleKeepPreviousLink: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + IconToggleItem( + iconRes = R.drawable.ic_clipboard_24, + title = "Clipboard Sync", + description = "Update Android clipboard automatically", + isChecked = isClipboardSyncEnabled, + onCheckedChange = onToggleClipboardSync + ) + IconToggleItem( + iconRes = R.drawable.outline_open_in_browser_24, + title = "Continue browsing", + description = continueBrowsingSubtitle, + isChecked = isContinueBrowsingEnabled, + onCheckedChange = onToggleContinueBrowsing, + enabled = isContinueBrowsingToggleEnabled + ) + IconToggleItem( + iconRes = R.drawable.rounded_history_24, + title = "Keep previous link", + description = "Without replacing", + isChecked = isKeepPreviousLinkEnabled, + onCheckedChange = onToggleKeepPreviousLink, + enabled = isContinueBrowsingToggleEnabled ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Clipboard Sync", style = MaterialTheme.typography.titleMedium) - Text( - "Unfortunately Google killed automatic sync, You need to manually share the text to AirSync app.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isClipboardSyncEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleClipboardSync(enabled) - } - ) - } - // Continue Browsing toggle displayed under clipboard sync - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Continue browsing", style = MaterialTheme.typography.titleMedium) - Text( - continueBrowsingSubtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(modifier = Modifier.padding(end = 8.dp)) - Switch( - checked = isContinueBrowsingEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleContinueBrowsing(enabled) - }, - enabled = isContinueBrowsingToggleEnabled - ) - } - - // Keep previous link toggle under Continue Browsing - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Keep previous link", style = MaterialTheme.typography.titleMedium) - Text( - "Keep multiple continue browsing notifications", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isKeepPreviousLinkEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleKeepPreviousLink(enabled) - }, - enabled = isContinueBrowsingToggleEnabled - ) - } - - } } } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt index 9398bdd6..447cc645 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt @@ -453,10 +453,10 @@ fun FeatureIntroStepContent( context, com.sameerasw.airsync.service.ClipboardTileService::class.java ), - isQuickShareTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( - context, - - ) +// isQuickShareTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( +// context, +// +// ) ) } 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 8027e0dc..768ad625 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 @@ -14,6 +14,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,6 +55,7 @@ import androidx.compose.material.icons.rounded.Phonelink import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -85,6 +87,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -841,8 +844,15 @@ fun AirSyncMainScreen( Card( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { - Column(modifier = Modifier.padding(16.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { Row( modifier = Modifier .fillMaxWidth() @@ -905,18 +915,14 @@ fun AirSyncMainScreen( discoveredDevices.forEachIndexed { index, device -> if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ) - ) + Spacer(modifier = Modifier.height(8.dp)) } Row( modifier = Modifier .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable { HapticUtil.performClick(haptics) viewModel.updateIpAddress(device.getBestIp()) @@ -926,7 +932,7 @@ fun AirSyncMainScreen( ) connect(device.id) } - .padding(vertical = 4.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( diff --git a/app/src/main/res/drawable/quick_share.xml b/app/src/main/res/drawable/quick_share.xml new file mode 100644 index 00000000..7eef4496 --- /dev/null +++ b/app/src/main/res/drawable/quick_share.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_bug_report_24.xml b/app/src/main/res/drawable/rounded_bug_report_24.xml new file mode 100644 index 00000000..2899c219 --- /dev/null +++ b/app/src/main/res/drawable/rounded_bug_report_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_dark_mode_24.xml b/app/src/main/res/drawable/rounded_dark_mode_24.xml new file mode 100644 index 00000000..0788c72e --- /dev/null +++ b/app/src/main/res/drawable/rounded_dark_mode_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_extension_24.xml b/app/src/main/res/drawable/rounded_extension_24.xml new file mode 100644 index 00000000..73d85e26 --- /dev/null +++ b/app/src/main/res/drawable/rounded_extension_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_history_24.xml b/app/src/main/res/drawable/rounded_history_24.xml new file mode 100644 index 00000000..a273bff9 --- /dev/null +++ b/app/src/main/res/drawable/rounded_history_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_music_cast_24.xml b/app/src/main/res/drawable/rounded_music_cast_24.xml new file mode 100644 index 00000000..4fd8dcc2 --- /dev/null +++ b/app/src/main/res/drawable/rounded_music_cast_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_shield_toggle_24.xml b/app/src/main/res/drawable/rounded_shield_toggle_24.xml new file mode 100644 index 00000000..74e500a3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_shield_toggle_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_smart_display_24.xml b/app/src/main/res/drawable/rounded_smart_display_24.xml new file mode 100644 index 00000000..e653df58 --- /dev/null +++ b/app/src/main/res/drawable/rounded_smart_display_24.xml @@ -0,0 +1,20 @@ + + + + + + From 2c64c997d19d6f7a9426e64a1b2c84612ac47f4f Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 19 May 2026 17:36:21 +0530 Subject: [PATCH 15/33] feat: media progress bar --- .../ui/components/FloatingMediaPlayer.kt | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) 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 8f390c44..d0eb0811 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.ButtonGroup import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -59,6 +60,7 @@ import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation @@ -101,6 +103,21 @@ fun FloatingMediaPlayer( val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current + var currentElapsedTimeMs by remember(musicInfo) { mutableStateOf(musicInfo?.elapsedTime ?: 0L) } + + LaunchedEffect(musicInfo?.isPlaying, musicInfo?.elapsedTime) { + if (musicInfo?.isPlaying == true) { + var lastTime = System.currentTimeMillis() + while (true) { + kotlinx.coroutines.delay(500) + val now = System.currentTimeMillis() + val delta = (now - lastTime) * ((musicInfo.playbackRate).toFloat()) + currentElapsedTimeMs += delta.toLong() + lastTime = now + } + } + } + val collapsedHeight = 72.dp val expandedHeight = 280.dp @@ -199,7 +216,7 @@ fun FloatingMediaPlayer( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), color = MaterialTheme.colorScheme.onSurface ) Text( @@ -207,7 +224,7 @@ fun FloatingMediaPlayer( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis + modifier = Modifier.basicMarquee(), ) } @@ -273,6 +290,44 @@ fun FloatingMediaPlayer( Spacer(modifier = Modifier.size(48.dp)) // To balance the chevron } + if (musicInfo != null && musicInfo.duration > 0) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val durationSeconds = musicInfo.duration / 1000L + val elapsedSeconds = (currentElapsedTimeMs / 1000L).coerceIn(0L, durationSeconds) + val elapsedFraction = (currentElapsedTimeMs.toFloat() / musicInfo.duration.toFloat()).coerceIn(0f, 1f) + + LinearWavyProgressIndicator( + progress = { elapsedFraction }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer, + wavelength = 20.dp, + amplitude = { if (musicInfo.isPlaying) 1.0f else 0f } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatTime(elapsedSeconds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatTime(durationSeconds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + // Media Controls Row( modifier = Modifier.fillMaxWidth(), @@ -350,3 +405,9 @@ fun FloatingMediaPlayer( fun lerp(start: Float, stop: Float, fraction: Float): Float { return (1 - fraction) * start + fraction * stop } + +private fun formatTime(seconds: Long): String { + val mins = seconds / 60 + val secs = seconds % 60 + return "$mins:${if (secs < 10) "0" else ""}$secs" +} From 4da51ab9097d6ae15a205a9b44a0f0fd029763c9 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 20 May 2026 13:45:17 +0530 Subject: [PATCH 16/33] feat: Disable BLE while regular conneciton active --- .../airsync/data/ble/BleConnectionManager.kt | 27 ++++++++++------ .../airsync/data/ble/BleGattServer.kt | 32 ++++++++++++++++++- 2 files changed, 49 insertions(+), 10 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 e7db1bac..a74882b0 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 @@ -40,22 +40,31 @@ class BleConnectionManager(private val context: Context) { dataStoreManager.getBleSyncEnabled(), dataStoreManager.getBleAutoConnectEnabled(), WebSocketUtil.connectionState - ) { enabled, auto, wsState -> - Triple(enabled, auto, wsState) - }.collectLatest { (enabled, _, _) -> + ) { enabled, auto, wsConnected -> + Triple(enabled, auto, wsConnected) + }.collectLatest { (enabled, _, wsConnected) -> isBleEnabled = enabled - updateBleState() + updateBleState(regularConnectionActive = wsConnected) } } } - private fun updateBleState() { - if (isBleEnabled) { - Log.d(TAG, "BLE enabled, starting GATT server") - bleServer?.start() - } else { + private fun updateBleState(regularConnectionActive: Boolean) { + if (!isBleEnabled) { Log.d(TAG, "BLE disabled, stopping server") bleServer?.stop() + return + } + + if (regularConnectionActive) { + // Regular Wi-Fi/USB connection is up — pause advertising to save power. + Log.d(TAG, "Regular connection active — pausing BLE advertising") + bleServer?.pauseAdvertising() + } else { + // No regular connection — ensure server is started and advertising. + Log.d(TAG, "No regular connection — resuming BLE advertising") + bleServer?.start() + bleServer?.resumeAdvertising() } } 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 5264a8f2..671ec295 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 @@ -48,6 +48,7 @@ class BleGattServer(private val context: Context) { private set private var negotiatedMtu = 23 private var heartbeatJob: Job? = null + private var isAdvertisingPaused = false enum class BleConnectionState { DISCONNECTED, ADVERTISING, CONNECTED, AUTHENTICATED @@ -124,6 +125,7 @@ class BleGattServer(private val context: Context) { pendingServices.clear() _connectionState.value = BleConnectionState.DISCONNECTED isAuthenticated = false + isAdvertisingPaused = false } private fun setupGattServer() { @@ -227,6 +229,32 @@ class BleGattServer(private val context: Context) { currentAdvertiseCallback = null } + /** + * Stop advertising while keeping the GATT server alive. + * Existing BLE connections remain active + */ + fun pauseAdvertising() { + if (isAdvertisingPaused) return + Log.d(TAG, "BLE advertising paused (regular connection active)") + isAdvertisingPaused = true + stopAdvertising() + if (_connectionState.value == BleConnectionState.ADVERTISING) { + _connectionState.value = BleConnectionState.CONNECTED + } + } + + /** + * Resume advertising after it was paused. No-op if already advertising. + */ + fun resumeAdvertising() { + if (!isAdvertisingPaused) return + if (gattServer == null) return + if (_connectionState.value == BleConnectionState.DISCONNECTED) return + Log.d(TAG, "BLE advertising resumed") + isAdvertisingPaused = false + startAdvertising() + } + private val gattServerCallback = object : BluetoothGattServerCallback() { override fun onServiceAdded(status: Int, service: BluetoothGattService) { Log.d(TAG, "Service added: ${service.uuid}, status: $status") @@ -260,7 +288,9 @@ class BleGattServer(private val context: Context) { false } if (isEnabled) { - startAdvertising() + if (!isAdvertisingPaused) { + startAdvertising() + } } else { Log.d(TAG, "BLE Sync is disabled, stopping server") stop() From 0c6fc2712d9299925c863d0d6437e76c72ae9300 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 21 May 2026 14:35:35 +0530 Subject: [PATCH 17/33] feat: WebDAV for remote file browsing --- app/build.gradle.kts | 8 + .../airsync/service/AirSyncService.kt | 39 ++- .../sameerasw/airsync/utils/WebDavServer.kt | 222 ++++++++++++++++++ gradle/libs.versions.toml | 9 + 4 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db4c1464..4f58ffbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -154,6 +154,14 @@ dependencies { implementation(libs.wire.runtime) implementation(libs.bouncycastle) + + // Ktor Server for WebDAV + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.host.common) + implementation(libs.ktor.server.status.pages) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.serialization.gson) } wire { diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index f5d40659..853494f7 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -17,8 +17,12 @@ import android.util.Log import androidx.core.app.NotificationCompat import com.sameerasw.airsync.MainActivity import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.utils.DiscoveryMode +import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.UDPDiscoveryManager +import com.sameerasw.airsync.utils.WebDavServer import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -38,6 +42,8 @@ class AirSyncService : Service() { private var connectedDeviceName: String? = null private var isScanning = false + private var webDavServer: WebDavServer? = null + // Network state tracking private var networkCallback: ConnectivityManager.NetworkCallback? = null @@ -45,7 +51,7 @@ class AirSyncService : Service() { super.onCreate() Log.d(TAG, "AirSyncService created") createNotificationChannel() - com.sameerasw.airsync.utils.MacDeviceStatusManager.startMonitoring(this) + MacDeviceStatusManager.startMonitoring(this) registerNetworkCallback() } @@ -58,7 +64,7 @@ class AirSyncService : Service() { ACTION_START_SYNC -> { connectedDeviceName = intent.getStringExtra(EXTRA_DEVICE_NAME) ?: "Mac" startSync() - com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, true) + ShortcutUtil.refreshShortcuts(this, true) } ACTION_STOP_SYNC -> stopSync() @@ -83,7 +89,7 @@ class AirSyncService : Service() { startForeground(NOTIFICATION_ID, buildNotification()) val dataStoreManager = - com.sameerasw.airsync.data.local.DataStoreManager.getInstance(applicationContext) + DataStoreManager.getInstance(applicationContext) val isDiscoveryEnabled = runBlocking { dataStoreManager.getDeviceDiscoveryEnabled().first() } @@ -101,6 +107,18 @@ class AirSyncService : Service() { WebSocketUtil.requestAutoReconnect(this) } + private fun startWebDavServer() { + if (webDavServer == null) { + webDavServer = WebDavServer(this) + } + webDavServer?.start() + } + + private fun stopWebDavServer() { + webDavServer?.stop() + webDavServer = null + } + private fun handleAppForeground() { if (isScanning) { Log.d(TAG, "App in foreground, switching to ACTIVE discovery") @@ -118,12 +136,16 @@ class AirSyncService : Service() { } private fun startSync() { + if (!isScanning && connectedDeviceName != null) { + Log.d(TAG, "AirSync foreground service already in sync state, ignoring") + return + } Log.d(TAG, "Starting AirSync foreground service (connected)") isScanning = false startForeground(NOTIFICATION_ID, buildNotification()) val dataStoreManager = - com.sameerasw.airsync.data.local.DataStoreManager.getInstance(applicationContext) + DataStoreManager.getInstance(applicationContext) val isDiscoveryEnabled = runBlocking { dataStoreManager.getDeviceDiscoveryEnabled().first() } @@ -134,11 +156,13 @@ class AirSyncService : Service() { UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.PASSIVE) WakeupService.startService(this) + startWebDavServer() } private fun stopSync() { Log.d(TAG, "Stopping AirSync foreground service") - com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, false) + stopWebDavServer() + ShortcutUtil.refreshShortcuts(this, false) UDPDiscoveryManager.stop(this) WakeupService.stopService(this) stopForeground(STOP_FOREGROUND_REMOVE) @@ -234,8 +258,9 @@ class AirSyncService : Service() { } } - com.sameerasw.airsync.utils.MacDeviceStatusManager.stopMonitoring() - com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup(this) + stopWebDavServer() + MacDeviceStatusManager.stopMonitoring() + MacDeviceStatusManager.cleanup(this) scope.coroutineContext.cancel() super.onDestroy() } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt new file mode 100644 index 00000000..22c94ed3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt @@ -0,0 +1,222 @@ +package com.sameerasw.airsync.utils + +import android.content.Context +import android.os.Environment +import android.util.Log +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.cio.* +import io.ktor.server.engine.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.request.path +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.io.File +import java.net.ServerSocket +import java.net.URLDecoder +import java.text.SimpleDateFormat +import java.util.* + +class WebDavServer(private val context: Context) { + private var engine: ApplicationEngine? = null + private val port = 9081 + private val TAG = "WebDavServer" + + private val storageRoot = Environment.getExternalStorageDirectory() + + fun start() { + if (engine != null) { + Log.d(TAG, "WebDAV server already initialized") + return + } + + if (!isPortAvailable(port)) { + Log.e(TAG, "WebDAV server cannot start: Port $port is already in use") + return + } + + try { + engine = embeddedServer(CIO, port = port, host = "0.0.0.0") { + install(StatusPages) { + exception { call, cause -> + Log.e(TAG, "Unhandled exception in route", cause) + call.respond(HttpStatusCode.InternalServerError, "Internal Server Error") + } + } + + routing { + // Catch-all: matches any path including root + route("{...}") { + method(HttpMethod.parse("PROPFIND")) { + handle { handlePropfind(call) } + } + get { handleGet(call) } + head { handleHead(call) } + method(HttpMethod.Options) { + handle { + call.response.header("Allow", "GET, HEAD, OPTIONS, PROPFIND") + call.response.header("DAV", "1, 2") + call.respond(HttpStatusCode.OK) + } + } + } + } + } + engine?.start(wait = false) + Log.i(TAG, "WebDAV server started on port $port") + } catch (e: Exception) { + Log.e(TAG, "Failed to start WebDAV server on port $port", e) + engine = null + } + } + + private fun isPortAvailable(port: Int): Boolean { + return try { + ServerSocket(port).use { true } + } catch (e: Exception) { + false + } + } + + fun stop() { + try { + engine?.stop(500, 1000) + } catch (e: Exception) { + Log.e(TAG, "Error stopping WebDAV server", e) + } finally { + engine = null + Log.i(TAG, "WebDAV server stopped") + } + } + + /** + * Extracts the filesystem-relative path from the request URI. + * URL-decodes the path and strips the leading slash so it can be + * joined with storageRoot via File(storageRoot, relativePath). + * Trailing slashes are removed before file resolution. + */ + private fun resolveRequestPath(call: ApplicationCall): String { + val raw = call.request.path() // e.g. "/", "/DCIM/", "/DCIM/Camera/IMG_001.jpg" + val decoded = URLDecoder.decode(raw, "UTF-8") + return decoded.trimStart('/').trimEnd('/') + } + + private suspend fun handlePropfind(call: ApplicationCall) { + val relativePath = resolveRequestPath(call) + val file = File(storageRoot, relativePath) + + Log.d(TAG, "PROPFIND: raw=${call.request.path()} -> resolved=${file.absolutePath}") + + if (!file.exists()) { + Log.w(TAG, "PROPFIND 404: ${file.absolutePath}") + call.respond(HttpStatusCode.NotFound) + return + } + + val depth = call.request.headers["Depth"] ?: "1" + val xml = buildPropfindXml(file, relativePath, depth) + + call.respondText(xml, ContentType.Text.Xml.withParameter("charset", "utf-8"), HttpStatusCode.MultiStatus) + } + + private fun buildPropfindXml(file: File, relativePath: String, depth: String): String { + val sb = StringBuilder() + sb.append("\n") + sb.append("\n") + + appendFileEntry(sb, file, relativePath) + + if (file.isDirectory && depth != "0") { + file.listFiles() + ?.filter { !it.name.startsWith(".") } + ?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) + ?.forEach { child -> + val childRelPath = if (relativePath.isEmpty()) child.name else "$relativePath/${child.name}" + appendFileEntry(sb, child, childRelPath) + } + } + + sb.append("") + return sb.toString() + } + + private fun appendFileEntry(sb: StringBuilder, file: File, relativePath: String) { + val displayName = if (relativePath.isEmpty()) "Android" else file.name + + // Build the href: each path segment is percent-encoded individually, + // but the "/" separators are preserved. Root is always "/". + val href = if (relativePath.isEmpty()) { + "/" + } else { + val encodedSegments = relativePath.split("/").joinToString("/") { segment -> + segment.encodeURLPathPart() + } + // Directories must have trailing slash for WebDAV clients to recognise them + if (file.isDirectory) "/$encodedSegments/" else "/$encodedSegments" + } + + val rfc1123Format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + val lastModified = rfc1123Format.format(Date(file.lastModified())) + + sb.append(" \n") + sb.append(" $href\n") + sb.append(" \n") + sb.append(" \n") + sb.append(" $displayName\n") + if (file.isDirectory) { + sb.append(" \n") + } else { + sb.append(" \n") + sb.append(" ${file.length()}\n") + val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + sb.append(" $contentType\n") + } + sb.append(" $lastModified\n") + sb.append(" \n") + sb.append(" HTTP/1.1 200 OK\n") + sb.append(" \n") + sb.append(" \n") + } + + private suspend fun handleGet(call: ApplicationCall) { + val relativePath = resolveRequestPath(call) + val file = File(storageRoot, relativePath) + + Log.d(TAG, "GET: raw=${call.request.path()} -> resolved=${file.absolutePath}") + + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound) + return + } + + if (file.isDirectory) { + call.respond(HttpStatusCode.MethodNotAllowed, "Cannot GET a directory") + return + } + + call.respondFile(file) + } + + private suspend fun handleHead(call: ApplicationCall) { + val relativePath = resolveRequestPath(call) + val file = File(storageRoot, relativePath) + + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound) + return + } + + if (!file.isDirectory) { + call.response.header(HttpHeaders.ContentLength, file.length().toString()) + val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + call.response.header(HttpHeaders.ContentType, contentType) + } + val rfc1123Format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + call.response.header(HttpHeaders.LastModified, rfc1123Format.format(Date(file.lastModified()))) + call.respond(HttpStatusCode.OK) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 894c19be..effcd10d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ sentry = "8.0.0" protobuf = "4.28.2" wire = "6.0.0-alpha03" bouncycastle = "1.78.1" +ktor = "2.3.12" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -55,6 +56,14 @@ sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = " wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version.ref = "wire" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } +# Ktor Server for WebDAV +ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } +ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } +ktor-server-host-common = { group = "io.ktor", name = "ktor-server-host-common", version.ref = "ktor" } +ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" } +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } +ktor-serialization-gson = { group = "io.ktor", name = "ktor-serialization-gson", version.ref = "ktor" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } From 4fd637609a9f7852ca22657401524abb7d57bc7a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 21 May 2026 17:57:59 +0530 Subject: [PATCH 18/33] feat: WebDAV toggle and plus license handling --- .../airsync/data/local/DataStoreManager.kt | 13 +++++++++ .../data/repository/AirSyncRepositoryImpl.kt | 8 ++++++ .../sameerasw/airsync/domain/model/UiState.kt | 1 + .../domain/repository/AirSyncRepository.kt | 4 +++ .../ui/components/SettingsView.kt | 11 ++++++++ .../viewmodel/AirSyncViewModel.kt | 15 ++++++++++ .../airsync/service/AirSyncService.kt | 28 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 ++ 8 files changed, 81 insertions(+), 1 deletion(-) 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 78538e83..a7aad644 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 @@ -92,6 +92,7 @@ class DataStoreManager(private val context: Context) { private val PITCH_BLACK_THEME = booleanPreferencesKey("pitch_black_theme") private val SENTRY_REPORTING_ENABLED = booleanPreferencesKey("sentry_reporting_enabled") private val QUICK_SHARE_ENABLED = booleanPreferencesKey("quick_share_enabled") + private val FILE_ACCESS_ENABLED = booleanPreferencesKey("file_access_enabled") // Widget preferences private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") @@ -346,6 +347,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setFileAccessEnabled(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[FILE_ACCESS_ENABLED] = enabled + } + } + + fun isFileAccessEnabled(): Flow { + return context.dataStore.data.map { preferences -> + preferences[FILE_ACCESS_ENABLED] != false // Default to enabled + } + } + suspend fun setDefaultTab(tab: String) { context.dataStore.edit { prefs -> prefs[DEFAULT_TAB] = tab 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 02c5ee52..397217b6 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 @@ -295,4 +295,12 @@ class AirSyncRepositoryImpl( override fun isQuickShareEnabled(): Flow { return dataStoreManager.isQuickShareEnabled() } + + override suspend fun setFileAccessEnabled(enabled: Boolean) { + dataStoreManager.setFileAccessEnabled(enabled) + } + + override fun isFileAccessEnabled(): Flow { + return dataStoreManager.isFileAccessEnabled() + } } \ No newline at end of file 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 cb64daeb..44bd392e 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 @@ -50,5 +50,6 @@ data class UiState( val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, val isQuickShareEnabled: Boolean = false, + val isFileAccessEnabled: Boolean = true, val bleConnectionState: com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState = com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.DISCONNECTED ) \ No newline at end of file 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 32f2defd..ab1420d3 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 @@ -132,4 +132,8 @@ interface AirSyncRepository { // Quick Share (receiving) suspend fun setQuickShareEnabled(enabled: Boolean) fun isQuickShareEnabled(): Flow + + // File Access (WebDAV Server) + suspend fun setFileAccessEnabled(enabled: Boolean) + fun isFileAccessEnabled(): Flow } \ No newline at end of file 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 2af74a68..b16644f4 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 @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.DeviceInfo @@ -242,6 +243,16 @@ fun SettingsView( viewModel.setQuickShareEnabled(context, enabled) } ) + + IconToggleItem( + title = stringResource(R.string.label_file_access), + description = stringResource(R.string.subtitle_file_access), + iconRes = R.drawable.rounded_folder_managed_24, + isChecked = uiState.isFileAccessEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setFileAccessEnabled(context, enabled) + } + ) } } 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 ae16cb96..89a034a5 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 @@ -184,6 +184,13 @@ class AirSyncViewModel( } } + // Observe File Access preference + viewModelScope.launch { + repository.isFileAccessEnabled().collect { enabled -> + _uiState.value = _uiState.value.copy(isFileAccessEnabled = enabled) + } + } + // Observe BLE connection status viewModelScope.launch { com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.connectionState?.collect { state -> @@ -691,6 +698,14 @@ class AirSyncViewModel( } } + fun setFileAccessEnabled(context: Context, enabled: Boolean) { + _uiState.value = _uiState.value.copy(isFileAccessEnabled = enabled) + viewModelScope.launch { + repository.setFileAccessEnabled(enabled) + ServiceManager.updateServiceState(context) + } + } + fun manualSyncAppIcons(context: Context) { _uiState.value = _uiState.value.copy(isIconSyncLoading = true, iconSyncMessage = "") diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 853494f7..d5391863 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -28,7 +28,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking /** @@ -43,6 +45,7 @@ class AirSyncService : Service() { private var isScanning = false private var webDavServer: WebDavServer? = null + private var webDavJob: Job? = null // Network state tracking private var networkCallback: ConnectivityManager.NetworkCallback? = null @@ -119,6 +122,27 @@ class AirSyncService : Service() { webDavServer = null } + private fun monitorWebDavRequirements() { + webDavJob?.cancel() + webDavJob = scope.launch { + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + combine( + dataStoreManager.isFileAccessEnabled(), + dataStoreManager.getLastConnectedDevice() + ) { isEnabled, device -> + Log.d(TAG, "WebDAV flow evaluation: isEnabled=$isEnabled, isPlus=${device?.isPlus}") + isEnabled && device?.isPlus == true + }.collect { shouldStart -> + Log.d(TAG, "WebDAV requirement state updated: shouldStart = $shouldStart") + if (shouldStart) { + startWebDavServer() + } else { + stopWebDavServer() + } + } + } + } + private fun handleAppForeground() { if (isScanning) { Log.d(TAG, "App in foreground, switching to ACTIVE discovery") @@ -156,11 +180,13 @@ class AirSyncService : Service() { UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.PASSIVE) WakeupService.startService(this) - startWebDavServer() + monitorWebDavRequirements() } private fun stopSync() { Log.d(TAG, "Stopping AirSync foreground service") + webDavJob?.cancel() + webDavJob = null stopWebDavServer() ShortcutUtil.refreshShortcuts(this, false) UDPDiscoveryManager.stop(this) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e78a719..52e469b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,4 +89,6 @@ Attempted when disconnected unexpectedly Switch to Nearby Use Bluetooth LE if connection lost + File Access + Mount storage in macOS Finder \ No newline at end of file From d7f97fd66911f70fd27454a705a87c48b226f239 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Thu, 21 May 2026 14:24:28 +0530 Subject: [PATCH 19/33] feat: Implement Seekbar sync for now playing [2/2] Handle mediaControl seekTo commands on Android and sync playback position metadata back to macOS. - add seekTo transport control support - include duration, position, timestamp, and buffering in status payloads - update sync logic so seekbar state can reconcile after remote scrubs --- .../airsync/domain/model/DeviceStatus.kt | 10 ++++++- .../service/MediaNotificationListener.kt | 18 ++++++++++--- .../sameerasw/airsync/utils/DeviceInfoUtil.kt | 16 +++++++++-- .../com/sameerasw/airsync/utils/JsonUtil.kt | 8 ++++-- .../airsync/utils/MediaControlUtil.kt | 20 ++++++++++++++ .../sameerasw/airsync/utils/SyncManager.kt | 5 +++- .../airsync/utils/WebSocketMessageHandler.kt | 27 ++++++++++++------- 7 files changed, 85 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt index 6d942489..5e6efe6a 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt @@ -13,6 +13,10 @@ data class AudioInfo( val isMuted: Boolean, val albumArt: String? = null, val albumArtLite: String? = null, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val positionTimestampMs: Long = 0L, + val isBuffering: Boolean = false, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) @@ -23,6 +27,10 @@ data class MediaInfo( val artist: String, val albumArt: String? = null, val albumArtLite: String? = null, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val positionTimestampMs: Long = 0L, + val isBuffering: Boolean = false, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" -) \ No newline at end of file +) 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 13366288..40ab1648 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -86,7 +86,7 @@ class MediaNotificationListener : NotificationListenerService() { fun getMediaInfo(context: Context): MediaInfo { // Respect global toggle; if disabled, return empty media if (!isNowPlayingEnabled) { - return MediaInfo(false, "", "", null, "none") + return MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } return try { val mediaSessionManager = @@ -119,6 +119,14 @@ class MediaNotificationListener : NotificationListenerService() { val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "" val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "" val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING + val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L + val positionMs = playbackState?.position ?: 0L + val positionTimestampMs = System.currentTimeMillis() + val isBuffering = when (playbackState?.state) { + PlaybackState.STATE_BUFFERING, + PlaybackState.STATE_CONNECTING -> true + else -> false + } val albumArtBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) @@ -166,6 +174,10 @@ class MediaNotificationListener : NotificationListenerService() { artist = artist, albumArt = albumArtBase64, albumArtLite = albumArtLiteBase64, + durationMs = durationMs, + positionMs = positionMs, + positionTimestampMs = positionTimestampMs, + isBuffering = isBuffering, likeStatus = likeStatus ) } @@ -181,10 +193,10 @@ class MediaNotificationListener : NotificationListenerService() { } // Log.d(TAG, "No media info found") - MediaInfo(false, "", "", null, "none") + MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } catch (e: Exception) { Log.e(TAG, "Error getting media info: ${e.message}") - MediaInfo(false, "", "", null, "none") + MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt index 0658b703..49d006cd 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -152,6 +152,10 @@ object DeviceInfoUtil { isMuted = isMuted, albumArt = null, albumArtLite = null, + durationMs = 0L, + positionMs = 0L, + positionTimestampMs = 0L, + isBuffering = false, likeStatus = "none" ) } @@ -168,11 +172,15 @@ object DeviceInfoUtil { isMuted = isMuted, albumArt = mediaInfo.albumArt, albumArtLite = mediaInfo.albumArtLite, + durationMs = mediaInfo.durationMs, + positionMs = mediaInfo.positionMs, + positionTimestampMs = mediaInfo.positionTimestampMs, + isBuffering = mediaInfo.isBuffering, likeStatus = mediaInfo.likeStatus ) } catch (e: Exception) { Log.e("DeviceInfoUtil", "Error getting audio info: ${e.message}") - AudioInfo(false, "", "", 0, true, null, "none") + AudioInfo(false, "", "", 0, true, null, null, 0L, 0L, 0L, false, "none") } } @@ -191,6 +199,10 @@ object DeviceInfoUtil { isMuted = audioInfo.isMuted, albumArt = audioInfo.albumArt, albumArtLite = audioInfo.albumArtLite, + duration = audioInfo.durationMs, + position = audioInfo.positionMs, + positionTimestamp = audioInfo.positionTimestampMs, + isBuffering = audioInfo.isBuffering, likeStatus = audioInfo.likeStatus ) } @@ -215,4 +227,4 @@ object DeviceInfoUtil { val powerManager = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager return powerManager?.isPowerSaveMode == true } -} \ No newline at end of file +} 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 b8466fb0..0ee5ca05 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -157,11 +157,15 @@ object JsonUtil { isMuted: Boolean, albumArt: String?, albumArtLite: String? = null, + duration: Long = 0L, + position: Long = 0L, + positionTimestamp: Long = 0L, + isBuffering: Boolean = false, likeStatus: String ): String { 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,"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"}}}""" } /** @@ -266,4 +270,4 @@ object JsonUtil { ) }"}}""" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt index aaa0b7f6..6fc55738 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt @@ -100,6 +100,26 @@ object MediaControlUtil { } } + /** + * Seek active media playback to an absolute position in milliseconds. + */ + fun seekTo(context: Context, positionMs: Long): Boolean { + return try { + val controller = getActiveMediaController(context) + if (controller != null) { + controller.transportControls.seekTo(positionMs.coerceAtLeast(0L)) + Log.d(TAG, "Seeked media to ${positionMs}ms") + true + } else { + Log.w(TAG, "No active media controller for seek") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error in seekTo: ${e.message}") + false + } + } + /** * Toggle like status by invoking the Like/Unlike action in the active media notification. */ 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 3e3b7c8c..e3fe5cb9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -95,6 +95,9 @@ object SyncManager { last.artist != currentAudio.artist || last.volume != currentAudio.volume || last.isMuted != currentAudio.isMuted || + last.isBuffering != currentAudio.isBuffering || + last.durationMs != currentAudio.durationMs || + kotlin.math.abs(last.positionMs - currentAudio.positionMs) >= 5_000L || last.likeStatus != currentAudio.likeStatus ) { shouldSync = true @@ -618,4 +621,4 @@ object SyncManager { lastBatteryInfo = null lastVolume = -1 } -} \ No newline at end of file +} 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 cfaf25d9..09961f41 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -212,7 +212,7 @@ object WebSocketMessageHandler { } /** - * Handles media control commands (play/pause, next, previous, like). + * Handles media control commands (play/pause, seek, next, previous, like). * Sends a response back to Mac and updates local media state after a short delay. */ private fun handleMediaControl(context: Context, data: JSONObject?) { @@ -243,6 +243,13 @@ object WebSocketMessageHandler { message = if (success) "Playback paused" else "Failed to pause playback" } + "seekTo" -> { + val positionMs = data.optLong("positionMs", -1L) + success = positionMs >= 0L && MediaControlUtil.seekTo(context, positionMs) + message = + if (success) "Seeked to ${positionMs}ms" else "Failed to seek playback" + } + "next" -> { // Suppress automatic media updates before executing skip command SyncManager.suppressMediaUpdatesForSkip() @@ -289,14 +296,15 @@ object WebSocketMessageHandler { // Send updated media state after successful control if (success) { - // For track skip actions (next/previous), add a delay to allow media player to update - CoroutineScope(Dispatchers.IO).launch { - val delayMs = when (action) { - "next", "previous" -> 1200L - else -> 400L // smaller delay for like/others - } - delay(delayMs) - SyncManager.onMediaStateChanged(context) + // For track skip actions (next/previous), add a delay to allow media player to update + CoroutineScope(Dispatchers.IO).launch { + val delayMs = when (action) { + "seekTo" -> 650L + "next", "previous" -> 1200L + else -> 400L // smaller delay for like/others + } + delay(delayMs) + SyncManager.onMediaStateChanged(context) } } } catch (e: Exception) { @@ -915,4 +923,3 @@ object WebSocketMessageHandler { } } } - From 1157aacdd1ead40ac8861eb808f073bdfdf4e892 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 22 May 2026 01:14:41 +0530 Subject: [PATCH 20/33] build: upgrade Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9.4.1 --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index effcd10d..79dd1811 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.0.1" +agp = "9.2.1" kotlin = "2.3.0" coreKtx = "1.10.1" junit = "4.13.2" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d858fdf8..66fe04e3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Jul 28 23:54:01 IST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 098e2bc8e0d1d44470061c65c099c086a24baaa5 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 22 May 2026 01:34:51 +0530 Subject: [PATCH 21/33] feat: add support for ACCESS_LOCAL_NETWORK permission and update target SDK to 37 --- app/build.gradle.kts | 5 ++-- app/src/main/AndroidManifest.xml | 1 + .../com/sameerasw/airsync/MainActivity.kt | 26 ++++++++++++++++--- .../ui/activities/PermissionsActivity.kt | 15 +++++++++++ .../ui/components/dialogs/PermissionDialog.kt | 18 ++++++++++--- .../ui/screens/PermissionsScreen.kt | 14 ++++++++++ .../sameerasw/airsync/utils/PermissionUtil.kt | 22 ++++++++++++++++ .../airsync/utils/UDPDiscoveryManager.kt | 13 ++++++++++ app/src/main/res/values/strings.xml | 7 +++++ 9 files changed, 113 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f58ffbe..de7b4c5e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,12 +11,11 @@ plugins { android { namespace = "com.sameerasw.airsync" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.sameerasw.airsync" minSdk = 30 - targetSdk = 36 versionCode = 27 versionName = "3.1.0" @@ -59,8 +58,10 @@ kotlin { compose = true buildConfig = true } + compileSdkMinor = 0 defaultConfig { + targetSdk = 37 buildConfigField("String", "MIN_MAC_APP_VERSION", "\"3.0.0\"") } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4030914d..02694662 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index df8c8470..c9848bc0 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -60,6 +60,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.WebSocketUtil import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -339,8 +340,12 @@ class MainActivity : ComponentActivity() { handleNotesRoleIntent(intent) // Start ADB discovery once at app startup and keep it running - AdbDiscoveryHolder.initialize(this) - Log.d("MainActivity", "Started persistent ADB discovery at app startup") + if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { + AdbDiscoveryHolder.initialize(this) + Log.d("MainActivity", "Started persistent ADB discovery at app startup") + } else { + Log.d("MainActivity", "Skipping persistent ADB discovery at startup: ACCESS_LOCAL_NETWORK permission not granted") + } // Check if this is a QS tile long-press intent and device is not connected if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") { @@ -581,11 +586,26 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { + AdbDiscoveryHolder.initialize(this) + val ds = DataStoreManager.getInstance(applicationContext) + val isDiscoveryEnabled = runBlocking { + ds.getDeviceDiscoveryEnabled().first() + } + UDPDiscoveryManager.start(this, isDiscoveryEnabled) + UDPDiscoveryManager.burstBroadcast(this) + } + } + /** * Ensure ADB discovery is running (started at app startup, this just verifies it's active). */ fun initializeAdbDiscovery() { - AdbDiscoveryHolder.initialize(this) + if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { + AdbDiscoveryHolder.initialize(this) + } } /** diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt index 27c199b9..c83d4462 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt @@ -55,6 +55,10 @@ class PermissionsActivity : ComponentActivity() { ActivityResultContracts.RequestMultiplePermissions() ) { refreshUI() } + private val localNetworkPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { refreshUI() } + private var refreshCounter by mutableStateOf(0) @OptIn(ExperimentalMaterial3Api::class) @@ -127,6 +131,9 @@ class PermissionsActivity : ComponentActivity() { onRequestBluetoothPermission = { requestBluetoothPermission() }, + onRequestLocalNetworkPermission = { + requestLocalNetworkPermission() + }, refreshTrigger = refreshCounter ) } @@ -178,6 +185,14 @@ class PermissionsActivity : ComponentActivity() { } } + private fun requestLocalNetworkPermission() { + if (Build.VERSION.SDK_INT >= 37) { + if (!PermissionUtil.isLocalNetworkPermissionGranted(this)) { + localNetworkPermissionLauncher.launch("android.permission.ACCESS_LOCAL_NETWORK") + } + } + } + override fun onResume() { super.onResume() // Refresh permissions display when returning to this activity diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt index 180a1ca4..e48204a0 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.platform.LocalContext +import android.content.Context import com.sameerasw.airsync.R enum class PermissionType { @@ -36,7 +38,8 @@ enum class PermissionType { CALL_LOG, CONTACTS, PHONE, - BLUETOOTH + BLUETOOTH, + LOCAL_NETWORK } data class PermissionInfo( @@ -53,7 +56,8 @@ fun PermissionExplanationDialog( onDismiss: () -> Unit, onGrantPermission: () -> Unit ) { - val permissionInfo = getPermissionInfo(permissionType) + val context = LocalContext.current + val permissionInfo = getPermissionInfo(context, permissionType) Dialog( onDismissRequest = onDismiss, @@ -155,7 +159,7 @@ fun PermissionExplanationDialog( } } -private fun getPermissionInfo(permissionType: PermissionType): PermissionInfo { +private fun getPermissionInfo(context: Context, permissionType: PermissionType): PermissionInfo { return when (permissionType) { PermissionType.NOTIFICATION_ACCESS -> PermissionInfo( title = "Notification Access", @@ -220,5 +224,13 @@ private fun getPermissionInfo(permissionType: PermissionType): PermissionInfo { whyNeeded = "To discover and connect to your Mac via Bluetooth, Android requires Bluetooth permissions (Scan, Connect, and Advertise). \n\nThis enables a low-power background connection that keeps your devices synced even when they aren't on the same Wi-Fi network. AirSync only uses Bluetooth to communicate with your authorized Mac devices.", buttonText = "Grant Bluetooth Access" ) + + PermissionType.LOCAL_NETWORK -> PermissionInfo( + title = context.getString(R.string.permission_local_network_title), + icon = R.drawable.rounded_sync_desktop_24, + description = context.getString(R.string.permission_local_network_explain), + whyNeeded = context.getString(R.string.permission_local_network_why), + buttonText = context.getString(R.string.permission_local_network_button) + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt index 7ee62dc6..8833d343 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt @@ -44,6 +44,7 @@ fun PermissionsScreen( onRequestContactsPermission: (() -> Unit)? = null, onRequestPhonePermission: (() -> Unit)? = null, onRequestBluetoothPermission: (() -> Unit)? = null, + onRequestLocalNetworkPermission: (() -> Unit)? = null, refreshTrigger: Int = 0 ) { val context = LocalContext.current @@ -229,6 +230,15 @@ fun PermissionsScreen( isCritical = false ) } + + "Local Network Access" -> { + PermissionButton( + permissionName = permission, + description = "Discover nearby Mac devices on Wi-Fi", + onExplainClick = { showDialog = PermissionType.LOCAL_NETWORK }, + isCritical = false + ) + } } } } @@ -277,6 +287,10 @@ fun PermissionsScreen( PermissionType.BLUETOOTH -> { onRequestBluetoothPermission?.invoke() } + + PermissionType.LOCAL_NETWORK -> { + onRequestLocalNetworkPermission?.invoke() + } } } ) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt index 16a53e45..abdf7c91 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -102,6 +102,20 @@ object PermissionUtil { } } + /** + * Check if ACCESS_LOCAL_NETWORK permission is granted (Android 17+) + */ + fun isLocalNetworkPermissionGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 37) { + ContextCompat.checkSelfPermission( + context, + "android.permission.ACCESS_LOCAL_NETWORK" + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + /** * Check if notification permissions are required for this Android version */ @@ -191,6 +205,10 @@ object PermissionUtil { missing.add("Bluetooth Access") } + if (Build.VERSION.SDK_INT >= 37 && !isLocalNetworkPermissionGranted(context)) { + missing.add("Local Network Access") + } + return missing } @@ -248,6 +266,10 @@ object PermissionUtil { optional.add("Bluetooth Access") } + if (Build.VERSION.SDK_INT >= 37 && !isLocalNetworkPermissionGranted(context)) { + optional.add("Local Network Access") + } + return optional } 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 b1fb2fb5..71074fa0 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -93,6 +93,10 @@ object UDPDiscoveryManager { } fun start(context: Context, discoveryEnabled: Boolean = true) { + if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { + Log.d(TAG, "Skipping UDP Discovery Manager start: local network permission not granted") + return + } isDiscoveryEnabled = discoveryEnabled if (isRunning) { updateBroadcastingState(context) @@ -119,6 +123,10 @@ object UDPDiscoveryManager { } fun burstBroadcast(context: Context, durationMs: Long = 30000) { + if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { + Log.d(TAG, "Skipping burst broadcast: local network permission not granted") + return + } if (!isDiscoveryEnabled) { Log.d(TAG, "Discovery disabled, skipping burst broadcast") return @@ -139,6 +147,11 @@ object UDPDiscoveryManager { private fun updateBroadcastingState(context: Context) { broadcastJob?.cancel() + if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { + Log.d(TAG, "Skipping broadcasting state update: local network permission not granted") + return + } + if (!isDiscoveryEnabled) { Log.d(TAG, "Discovery broadcasting disabled completely") _discoveredDevices.value = emptyList() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52e469b5..aee03604 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,4 +91,11 @@ Use Bluetooth LE if connection lost File Access Mount storage in macOS Finder + + + Local Network Access + Discover nearby Mac devices on Wi-Fi + AirSync needs access to your local network to discover and connect to your Mac over Wi-Fi. + Local network discovery allows the app to find and establish a secure, fast connection with your Mac on the same Wi-Fi network for syncing notifications, clipboard, and controlling media controls. Without this, Wi-Fi synchronization cannot function on Android 17+. + Grant Local Network Access \ No newline at end of file From 86b62231863790a2e2806ef2048dc465ea8d8271 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 22 May 2026 01:37:58 +0530 Subject: [PATCH 22/33] build: upgrade source and target compatibility to Java 21 --- app/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index de7b4c5e..23470417 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,12 +46,12 @@ android { } } compileOptions { - sourceCompatibility = VERSION_11 - targetCompatibility = VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlin { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_21) } } buildFeatures { From 9e292174e448653389516e64f883b1582fc2a052 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 22 May 2026 01:48:50 +0530 Subject: [PATCH 23/33] chore: add Ktor proguard rules and configure foojay toolchain resolver --- app/proguard-rules.pro | 7 ++++++- settings.gradle.kts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4246abbd..2f40cbfd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -29,4 +29,9 @@ -keep class com.sameerasw.airsync.domain.model.** { *; } # Data Layer --keep class com.sameerasw.airsync.data.** { *; } \ No newline at end of file +-keep class com.sameerasw.airsync.data.** { *; } + +# Ktor & SLF4J missing classes on Android +-dontwarn java.lang.management.ManagementFactory +-dontwarn java.lang.management.RuntimeMXBean +-dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index c4f35140..9ff59c83 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From 5f683488c7d77b5d2c2b8482f550836078d0f6a6 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 23 May 2026 03:27:22 +0530 Subject: [PATCH 24/33] feat: implement websocket-based call control --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + .../airsync/data/ble/BleTransportBridge.kt | 3 + .../ui/activities/PermissionsActivity.kt | 15 +++ .../ui/components/dialogs/PermissionDialog.kt | 11 +- .../ui/screens/PermissionsScreen.kt | 14 +++ .../airsync/utils/CallControlUtil.kt | 101 ++++++++++++++++++ .../sameerasw/airsync/utils/PermissionUtil.kt | 22 ++++ .../airsync/utils/WebSocketMessageHandler.kt | 23 ++++ app/src/main/res/values/strings.xml | 7 ++ 10 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23470417..dfa73fda 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { defaultConfig { applicationId = "com.sameerasw.airsync" minSdk = 30 - versionCode = 27 - versionName = "3.1.0" + versionCode = 28 + versionName = "3.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 02694662..a9f19d1a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,7 @@ + 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 72b0ed89..1d2ecc8c 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 @@ -5,6 +5,7 @@ import com.sameerasw.airsync.domain.model.BatteryInfo import com.sameerasw.airsync.domain.model.AudioInfo import java.security.MessageDigest import java.util.* +import com.sameerasw.airsync.utils.CallControlUtil object BleTransportBridge { private const val TAG = "BleTransportBridge" @@ -82,6 +83,8 @@ object BleTransportBridge { "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) + "callAccept" -> CallControlUtil.acceptCall(context) + "callDecline", "callEnd" -> CallControlUtil.endCall(context) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt index c83d4462..3e0ad33e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt @@ -59,6 +59,10 @@ class PermissionsActivity : ComponentActivity() { ActivityResultContracts.RequestPermission() ) { refreshUI() } + private val answerCallsPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { refreshUI() } + private var refreshCounter by mutableStateOf(0) @OptIn(ExperimentalMaterial3Api::class) @@ -134,6 +138,9 @@ class PermissionsActivity : ComponentActivity() { onRequestLocalNetworkPermission = { requestLocalNetworkPermission() }, + onRequestAnswerCallsPermission = { + requestAnswerCallsPermission() + }, refreshTrigger = refreshCounter ) } @@ -193,6 +200,14 @@ class PermissionsActivity : ComponentActivity() { } } + private fun requestAnswerCallsPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!PermissionUtil.isAnswerCallsPermissionGranted(this)) { + answerCallsPermissionLauncher.launch(Manifest.permission.ANSWER_PHONE_CALLS) + } + } + } + override fun onResume() { super.onResume() // Refresh permissions display when returning to this activity diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt index e48204a0..d3ad30b4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt @@ -39,7 +39,8 @@ enum class PermissionType { CONTACTS, PHONE, BLUETOOTH, - LOCAL_NETWORK + LOCAL_NETWORK, + ANSWER_CALLS } data class PermissionInfo( @@ -232,5 +233,13 @@ private fun getPermissionInfo(context: Context, permissionType: PermissionType): whyNeeded = context.getString(R.string.permission_local_network_why), buttonText = context.getString(R.string.permission_local_network_button) ) + + PermissionType.ANSWER_CALLS -> PermissionInfo( + title = context.getString(R.string.permission_answer_calls_title), + icon = R.drawable.rounded_settings_phone_24, + description = context.getString(R.string.permission_answer_calls_explain), + whyNeeded = context.getString(R.string.permission_answer_calls_why), + buttonText = context.getString(R.string.permission_answer_calls_button) + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt index 8833d343..f051f478 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt @@ -45,6 +45,7 @@ fun PermissionsScreen( onRequestPhonePermission: (() -> Unit)? = null, onRequestBluetoothPermission: (() -> Unit)? = null, onRequestLocalNetworkPermission: (() -> Unit)? = null, + onRequestAnswerCallsPermission: (() -> Unit)? = null, refreshTrigger: Int = 0 ) { val context = LocalContext.current @@ -239,6 +240,15 @@ fun PermissionsScreen( isCritical = false ) } + + "Answer Calls" -> { + PermissionButton( + permissionName = permission, + description = "Accept and end calls from Mac", + onExplainClick = { showDialog = PermissionType.ANSWER_CALLS }, + isCritical = false + ) + } } } } @@ -291,6 +301,10 @@ fun PermissionsScreen( PermissionType.LOCAL_NETWORK -> { onRequestLocalNetworkPermission?.invoke() } + + PermissionType.ANSWER_CALLS -> { + onRequestAnswerCallsPermission?.invoke() + } } } ) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt new file mode 100644 index 00000000..c76da93e --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt @@ -0,0 +1,101 @@ +package com.sameerasw.airsync.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioManager +import android.os.Build +import android.telecom.TelecomManager +import android.view.KeyEvent +import androidx.core.content.ContextCompat +import android.util.Log + +object CallControlUtil { + private const val TAG = "CallControlUtil" + + /** + * Programmatically accept an incoming call. + * Uses TelecomManager on API 26+ if permission is granted, falling back to KEYCODE_HEADSETHOOK emulation. + */ + fun acceptCall(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) { + try { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + if (telecomManager != null) { + Log.d(TAG, "Accepting ringing call via TelecomManager") + telecomManager.acceptRingingCall() + return + } + } catch (e: Exception) { + Log.e(TAG, "Failed to accept ringing call via TelecomManager, falling back", e) + } + } else { + Log.w(TAG, "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook") + } + } + + // Fallback: Dispatch HEADSETHOOK media key event + emulateHeadsetHookClick(context) + } + + /** + * Programmatically end or decline a call. + * Uses TelecomManager on API 28+ if permission is granted, falling back to KEYCODE_HEADSETHOOK emulation. + */ + fun endCall(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) { + try { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + if (telecomManager != null) { + Log.d(TAG, "Ending/declining call via TelecomManager") + val success = telecomManager.endCall() + Log.d(TAG, "TelecomManager.endCall returned: $success") + if (success) { + return + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to end call via TelecomManager, falling back", e) + } + } else { + Log.w(TAG, "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook") + } + } + + // Fallback: Dispatch HEADSETHOOK media key event + emulateHeadsetHookClick(context) + } + + /** + * Emulates clicking a hardware headset button (down and up events for KEYCODE_HEADSETHOOK). + * This acts as a reliable system-wide fallback to answer/end calls. + */ + private fun emulateHeadsetHookClick(context: Context) { + try { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager != null) { + Log.d(TAG, "Dispatching KEYCODE_HEADSETHOOK click to AudioManager") + val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK) + val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_HEADSETHOOK) + audioManager.dispatchMediaKeyEvent(downEvent) + audioManager.dispatchMediaKeyEvent(upEvent) + } else { + Log.e(TAG, "AudioManager not available, cannot emulate headset hook") + } + } catch (e: Exception) { + Log.e(TAG, "Error emulating headset hook click", e) + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt index abdf7c91..49da76d9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -209,6 +209,10 @@ object PermissionUtil { missing.add("Local Network Access") } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted(context)) { + missing.add("Answer Calls") + } + return missing } @@ -270,6 +274,10 @@ object PermissionUtil { optional.add("Local Network Access") } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted(context)) { + optional.add("Answer Calls") + } + return optional } @@ -315,4 +323,18 @@ object PermissionUtil { true } } + + /** + * Check if ANSWER_PHONE_CALLS permission is granted + */ + fun isAnswerCallsPermissionGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } } 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 09961f41..1c0cd75f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -87,6 +87,7 @@ object WebSocketMessageHandler { "refreshAdbPorts" -> handleRefreshAdbPorts(context) "browseLs" -> handleBrowseLs(context, data) "startQuickShare" -> handleStartQuickShare(context) + "callControl" -> handleCallControl(context, data) else -> { Log.w(TAG, "Unknown message type: $type") } @@ -313,6 +314,28 @@ object WebSocketMessageHandler { } } + /** + * Handles call control actions (accept, end, decline) from the Mac. + */ + private fun handleCallControl(context: Context, data: JSONObject?) { + try { + if (data == null) { + Log.e(TAG, "Call control data is null") + return + } + + val action = data.optString("action") + Log.d(TAG, "Handling call control action: $action") + when (action) { + "accept" -> CallControlUtil.acceptCall(context) + "end", "decline" -> CallControlUtil.endCall(context) + else -> Log.w(TAG, "Unknown call control action: $action") + } + } catch (e: Exception) { + Log.e(TAG, "Error handling call control command: ${e.message}") + } + } + /** * Attempts to dismiss a notification on the Android device by ID. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aee03604..d85b4764 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,4 +98,11 @@ AirSync needs access to your local network to discover and connect to your Mac over Wi-Fi. Local network discovery allows the app to find and establish a secure, fast connection with your Mac on the same Wi-Fi network for syncing notifications, clipboard, and controlling media controls. Without this, Wi-Fi synchronization cannot function on Android 17+. Grant Local Network Access + + + Answer Calls + Allows accepting and ending calls from your Mac + AirSync needs permission to manage phone calls so you can accept or decline them directly from your Mac companion window. + When you receive an incoming call, this permission enables the companion app to answer or decline/end the call programmatically in response to your actions on the Mac. Without this permission, the app will fall back to emulating headphone media click signals which may only toggle the call state rather than allowing explicit decline actions. + Grant Answer Calls Access \ No newline at end of file From 02f7c6dc40becef7ab16e7d8a54f6b7d7563d662 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 21:49:15 +0530 Subject: [PATCH 25/33] feat: initialize MediaSession in onCreate and ensure foreground service starts immediately in onStartCommand #205 --- .../airsync/service/MacMediaPlayerService.kt | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt index 5f91fdbe..6f9ef219 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt @@ -103,6 +103,36 @@ class MacMediaPlayerService : Service() { super.onCreate() serviceInstance = this createNotificationChannel() + try { + mediaSession = MediaSessionCompat(this, "MacMediaPlayer").apply { + setCallback(object : MediaSessionCompat.Callback() { + override fun onPlay() { + sendMacMediaControl("play") + updatePlaybackState(true) + } + + override fun onPause() { + sendMacMediaControl("pause") + updatePlaybackState(false) + } + + override fun onSkipToNext() { + sendMacMediaControl("next") + } + + override fun onSkipToPrevious() { + sendMacMediaControl("previous") + } + + override fun onStop() { + sendMacMediaControl("stop") + stopMacMediaSession() + } + }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to create MediaSession in onCreate: ${e.message}") + } Log.d(TAG, "MacMediaPlayerService created") } @@ -110,11 +140,18 @@ class MacMediaPlayerService : Service() { val action = intent?.action Log.d(TAG, "onStartCommand: action=$action") + // Immediately call startForeground to satisfy the Android OS watchdog + val initialTitle = intent?.getStringExtra(EXTRA_TITLE) ?: "" + val initialArtist = intent?.getStringExtra(EXTRA_ARTIST) ?: "" + val initialIsPlaying = intent?.getBooleanExtra(EXTRA_IS_PLAYING, false) ?: false + val notification = createMediaNotification(initialTitle, initialArtist, initialIsPlaying) + try { + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground in onStartCommand: ${e.message}") + } + if (action == ACTION_STOP_MAC_MEDIA || action == null) { - if (mediaSession == null) { - val notification = createMediaNotification("", "", false) - startForeground(NOTIFICATION_ID, notification) - } stopMacMediaSession() return START_NOT_STICKY } @@ -197,35 +234,6 @@ class MacMediaPlayerService : Service() { playbackRate: Double = 1.0 ) { try { - if (mediaSession == null) { - mediaSession = MediaSessionCompat(this, "MacMediaPlayer").apply { - setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { - sendMacMediaControl("play") - updatePlaybackState(true) - } - - override fun onPause() { - sendMacMediaControl("pause") - updatePlaybackState(false) - } - - override fun onSkipToNext() { - sendMacMediaControl("next") - } - - override fun onSkipToPrevious() { - sendMacMediaControl("previous") - } - - override fun onStop() { - sendMacMediaControl("stop") - stopMacMediaSession() - } - }) - } - } - updateMediaMetadata(title, artist, duration) updatePlaybackState(isPlaying, elapsedTime, timestamp, playbackRate) mediaSession?.isActive = true From c901bfcb7ab55a68b84079de61d034cd084c2c28 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 22:18:42 +0530 Subject: [PATCH 26/33] feat: add in-app notification app selection --- .../ui/components/SettingsView.kt | 36 +++ .../components/sheets/AppSelectionSheets.kt | 249 ++++++++++++++++++ .../viewmodel/AirSyncViewModel.kt | 42 +++ .../main/res/drawable/rounded_android_24.xml | 20 ++ app/src/main/res/values/strings.xml | 2 + 5 files changed, 349 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt create mode 100644 app/src/main/res/drawable/rounded_android_24.xml 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 b16644f4..76a7db2d 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 @@ -35,6 +35,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.sameerasw.airsync.presentation.ui.components.sheets.AppSelectionSheet import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback @@ -96,6 +102,7 @@ fun SettingsView( onToggleDeveloperMode: () -> Unit = {} ) { val haptics = LocalHapticFeedback.current + var showAppSelectionSheet by remember { mutableStateOf(false) } Column( modifier = modifier @@ -206,6 +213,20 @@ fun SettingsView( onGrantPermissions = { viewModel.setPermissionDialogVisible(true) } ) + if (uiState.isNotificationSyncEnabled && uiState.isNotificationEnabled) { + IconToggleItem( + title = stringResource(R.string.action_select_apps), + description = stringResource(R.string.subtitle_to_be_notified), + iconRes = R.drawable.rounded_notification_settings_24, + showToggle = false, + onClick = { + HapticUtil.performClick(haptics) + viewModel.loadNotificationApps(context) + showAppSelectionSheet = true + } + ) + } + ClipboardFeaturesCard( isClipboardSyncEnabled = uiState.isClipboardSyncEnabled, onToggleClipboardSync = { enabled: Boolean -> @@ -452,6 +473,21 @@ fun SettingsView( Spacer(modifier = Modifier.height(180.dp)) } + + if (showAppSelectionSheet) { + val apps by viewModel.notificationApps.collectAsState() + AppSelectionSheet( + onDismissRequest = { showAppSelectionSheet = false }, + apps = apps, + onAppToggle = { pkg, enabled -> + viewModel.toggleNotificationApp(context, pkg, enabled) + }, + onSaveAll = { updatedList -> + viewModel.saveAllNotificationApps(context, updatedList) + }, + isLoading = apps.isEmpty() + ) + } } @Composable diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt new file mode 100644 index 00000000..48772339 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt @@ -0,0 +1,249 @@ +package com.sameerasw.airsync.presentation.ui.components.sheets + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sameerasw.airsync.R +import com.sameerasw.airsync.domain.model.NotificationApp +import com.sameerasw.airsync.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppSelectionSheet( + onDismissRequest: () -> Unit, + apps: List, + onAppToggle: (String, Boolean) -> Unit, + onSaveAll: (List) -> Unit, + isLoading: Boolean +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val haptics = LocalHapticFeedback.current + var searchQuery by remember { mutableStateOf("") } + var showSystemApps by remember { mutableStateOf(false) } + + val filteredApps = apps.filter { + val matchesSearch = searchQuery.isEmpty() || it.appName.contains(searchQuery, ignoreCase = true) + val isVisible = !it.isSystemApp || showSystemApps || it.isEnabled + matchesSearch && isVisible + }.distinctBy { it.packageName } + .sortedWith(compareByDescending { it.isEnabled }.thenBy { it.appName.lowercase() }) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.action_select_apps), + style = MaterialTheme.typography.headlineSmall + ) + + IconButton( + onClick = { + HapticUtil.performClick(haptics) + val updatedList = apps.map { app -> + val isVisible = !app.isSystemApp || showSystemApps || app.isEnabled + if (isVisible) app.copy(isEnabled = !app.isEnabled) else app + } + onSaveAll(updatedList) + } + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_invert_colors_24), + contentDescription = "Invert Selection", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search apps") }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.outline_info_24), // Fallback search icon + contentDescription = "Search" + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + + // System Apps Toggle + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { + HapticUtil.performClick(haptics) + showSystemApps = !showSystemApps + } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_android_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Show system apps", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = showSystemApps, + onCheckedChange = { + HapticUtil.performClick(haptics) + showSystemApps = it + } + ) + } + + if (isLoading) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(24.dp)), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(filteredApps, key = { it.packageName }) { app -> + AppToggleItem( + icon = app.icon, + title = app.appName, + packageName = app.packageName, + isSystemApp = app.isSystemApp, + isChecked = app.isEnabled, + onCheckedChange = { isChecked -> + HapticUtil.performClick(haptics) + onAppToggle(app.packageName, isChecked) + } + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppToggleItem( + icon: Any?, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + packageName: String? = null, + isSystemApp: Boolean = false, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true +) { + val haptics = LocalHapticFeedback.current + val shouldShowSystemTag = isSystemApp + + ListItem( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled) { + HapticUtil.performClick(haptics) + onCheckedChange(!isChecked) + }, + leadingContent = { + if (icon != null) { + AsyncImage( + model = icon, + contentDescription = title, + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } else { + Spacer(modifier = Modifier.size(32.dp)) + } + }, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (shouldShowSystemTag) { + Icon( + painter = painterResource(id = R.drawable.rounded_android_24), + contentDescription = "System App", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + }, + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + trailingContent = { + Switch( + checked = if (enabled) isChecked else false, + onCheckedChange = null, + enabled = enabled + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) + ) +} 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 89a034a5..2e99cd73 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 @@ -26,6 +26,7 @@ import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.SyncManager import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -1132,4 +1133,45 @@ class AirSyncViewModel( } } + private val _notificationApps = MutableStateFlow>(emptyList()) + val notificationApps: StateFlow> = _notificationApps.asStateFlow() + + fun loadNotificationApps(context: Context) { + viewModelScope.launch(Dispatchers.IO) { + try { + val installed = com.sameerasw.airsync.utils.AppUtil.getInstalledApps(context) + val saved = repository.getNotificationApps().first() + val merged = com.sameerasw.airsync.utils.AppUtil.mergeWithSavedApps(installed, saved) + _notificationApps.value = merged + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to load notification apps: ${e.message}") + } + } + } + + fun toggleNotificationApp(context: Context, packageName: String, enabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + try { + val current = _notificationApps.value.map { + if (it.packageName == packageName) it.copy(isEnabled = enabled) else it + } + _notificationApps.value = current + repository.saveNotificationApps(current) + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to toggle notification app: ${e.message}") + } + } + } + + fun saveAllNotificationApps(context: Context, apps: List) { + viewModelScope.launch(Dispatchers.IO) { + try { + _notificationApps.value = apps + repository.saveNotificationApps(apps) + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to save all notification apps: ${e.message}") + } + } + } + } diff --git a/app/src/main/res/drawable/rounded_android_24.xml b/app/src/main/res/drawable/rounded_android_24.xml new file mode 100644 index 00000000..c412317c --- /dev/null +++ b/app/src/main/res/drawable/rounded_android_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d85b4764..cbbe1204 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,4 +105,6 @@ AirSync needs permission to manage phone calls so you can accept or decline them directly from your Mac companion window. When you receive an incoming call, this permission enables the companion app to answer or decline/end the call programmatically in response to your actions on the Mac. Without this permission, the app will fall back to emulating headphone media click signals which may only toggle the call state rather than allowing explicit decline actions. Grant Answer Calls Access + Select apps + To be notified \ No newline at end of file From 607d4d5f00edfb0f590dba0af1b4437f81b579ed Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 22:26:24 +0530 Subject: [PATCH 27/33] feat: update formatTime to support hour display in FloatingMediaPlayer --- .../presentation/ui/components/FloatingMediaPlayer.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 d0eb0811..91ccfc2d 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 @@ -407,7 +407,12 @@ fun lerp(start: Float, stop: Float, fraction: Float): Float { } private fun formatTime(seconds: Long): String { - val mins = seconds / 60 + val hours = seconds / 3600 + val mins = (seconds % 3600) / 60 val secs = seconds % 60 - return "$mins:${if (secs < 10) "0" else ""}$secs" + return if (hours > 0) { + "$hours:${if (mins < 10) "0" else ""}$mins:${if (secs < 10) "0" else ""}$secs" + } else { + "$mins:${if (secs < 10) "0" else ""}$secs" + } } From 9805f17f8060536d3abb5247ac3acaa0244bd2b0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 22:45:14 +0530 Subject: [PATCH 28/33] feat: add connection state check to prevent sync and notification events when WebSocket is disconnected --- .../com/sameerasw/airsync/service/CallReceiver.kt | 4 ++++ .../airsync/service/MediaNotificationListener.kt | 12 ++++++++++++ .../java/com/sameerasw/airsync/utils/SyncManager.kt | 3 +++ 3 files changed, 19 insertions(+) diff --git a/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt b/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt index 543e415a..8470591a 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.telephony.TelephonyManager import android.util.Log +import com.sameerasw.airsync.utils.WebSocketUtil /** * BroadcastReceiver that listens for telephony events (incoming/outgoing calls). @@ -20,6 +21,9 @@ class CallReceiver : BroadcastReceiver() { @Suppress("DEPRECATION") override fun onReceive(context: Context, intent: Intent) { + if (!WebSocketUtil.isConnected()) { + return + } Log.d(TAG, "Broadcast received: ${intent.action}") // Initialize the listener if it's the first time 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 40ab1648..c0f22a71 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -452,6 +452,9 @@ class MediaNotificationListener : NotificationListenerService() { override fun onNotificationPosted(sbn: StatusBarNotification?) { super.onNotificationPosted(sbn) + if (!WebSocketUtil.isConnected()) { + return + } sbn?.let { notification -> Log.d( TAG, @@ -485,12 +488,18 @@ class MediaNotificationListener : NotificationListenerService() { override fun onNotificationRemoved(sbn: StatusBarNotification?) { super.onNotificationRemoved(sbn) + if (!WebSocketUtil.isConnected()) { + return + } if (sbn != null) handleNotificationRemoval(sbn) } // API level variants call the same handler to ensure we catch swipe-away removals override fun onNotificationRemoved(sbn: StatusBarNotification, rankingMap: RankingMap) { super.onNotificationRemoved(sbn, rankingMap) + if (!WebSocketUtil.isConnected()) { + return + } handleNotificationRemoval(sbn) } @@ -500,6 +509,9 @@ class MediaNotificationListener : NotificationListenerService() { reason: Int ) { super.onNotificationRemoved(sbn, rankingMap, reason) + if (!WebSocketUtil.isConnected()) { + return + } handleNotificationRemoval(sbn) } 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 e3fe5cb9..cb414316 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -78,6 +78,9 @@ object SyncManager { } fun checkAndSyncDeviceStatus(context: Context, forceSync: Boolean = false) { + if (!WebSocketUtil.isConnected()) { + return + } CoroutineScope(Dispatchers.IO).launch { try { val dataStoreManager = DataStoreManager(context) From 344f1fbc11a08a3345ae62460f3ead59f0faffd8 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 22:46:33 +0530 Subject: [PATCH 29/33] refactor: prevent redundant network updates in NetworkMonitor by tracking last known state --- .../sameerasw/airsync/utils/NetworkMonitor.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt index 789d135d..71f5dfe2 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt @@ -22,6 +22,8 @@ object NetworkMonitor { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + var lastNetworkInfo: NetworkInfo? = null + fun getCurrentNetworkInfo(): NetworkInfo { val activeNetwork = connectivityManager.activeNetwork val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) @@ -35,27 +37,39 @@ object NetworkMonitor { return NetworkInfo(isConnected, isWifi, ipAddress) } + fun sendIfChanged() { + val info = getCurrentNetworkInfo() + if (info != lastNetworkInfo) { + lastNetworkInfo = info + trySend(info) + } + } + // Send initial state - trySend(getCurrentNetworkInfo()) + sendIfChanged() // Use NetworkCallback for newer versions val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { Log.d(TAG, "Network available: $network") - trySend(getCurrentNetworkInfo()) + sendIfChanged() } override fun onLost(network: Network) { Log.d(TAG, "Network lost: $network") - trySend(getCurrentNetworkInfo()) + sendIfChanged() } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { - Log.d(TAG, "Network capabilities changed: $network") - trySend(getCurrentNetworkInfo()) + val info = getCurrentNetworkInfo() + if (info != lastNetworkInfo) { + Log.d(TAG, "Network capabilities changed and network info updated: $network") + lastNetworkInfo = info + trySend(info) + } } } From 071fcb3500f8ee82db99122923ea9205ce2849b5 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 23:07:41 +0530 Subject: [PATCH 30/33] feat: Passive background discovery --- .../airsync/service/AirSyncService.kt | 4 +- .../airsync/utils/UDPDiscoveryManager.kt | 63 +++++++++++-------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index d5391863..400b434c 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -205,7 +205,9 @@ class AirSyncService : Service() { networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - Log.d(TAG, "Network available, triggering burst broadcast") + Log.d(TAG, "Network available, triggering burst broadcast and refreshing socket") + // Refresh UDP socket to bind to new network interface + UDPDiscoveryManager.refreshSocket() // When network becomes available, do a burst to announce ourselves if (isScanning) { UDPDiscoveryManager.burstBroadcast(applicationContext) 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 71074fa0..d520447f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -204,35 +204,47 @@ object UDPDiscoveryManager { } } - private fun startListening(context: Context) { - val appContext = context.applicationContext - listeningJob = CoroutineScope(Dispatchers.IO).launch { + fun refreshSocket() { + CoroutineScope(Dispatchers.IO).launch { try { - // Ensure socket is closed before creating new one + Log.d(TAG, "Refreshing UDP discovery socket due to network change") socket?.close() - socket = DatagramSocket(BROADCAST_PORT).apply { - broadcast = true - reuseAddress = true - soTimeout = 0 - } - - val buffer = ByteArray(4096) - while (isRunning) { - try { - val packet = DatagramPacket(buffer, buffer.size) - socket?.receive(packet) + socket = null + } catch (e: Exception) { + Log.e(TAG, "Error refreshing socket: ${e.message}") + } + } + } - val jsonString = String(packet.data, 0, packet.length) - handleIncomingTraffic(appContext, jsonString, packet.address.hostAddress) - } catch (e: Exception) { - if (isRunning) { - Log.e(TAG, "Error receiving packet: ${e.message}") - delay(1000) + private fun startListening(context: Context) { + val appContext = context.applicationContext + listeningJob = CoroutineScope(Dispatchers.IO).launch { + val buffer = ByteArray(4096) + while (isRunning) { + try { + if (socket == null || socket!!.isClosed) { + Log.d(TAG, "Creating new DatagramSocket on port $BROADCAST_PORT") + socket = DatagramSocket(BROADCAST_PORT).apply { + broadcast = true + reuseAddress = true + soTimeout = 0 } } + val packet = DatagramPacket(buffer, buffer.size) + socket?.receive(packet) + + val jsonString = String(packet.data, 0, packet.length) + handleIncomingTraffic(appContext, jsonString, packet.address.hostAddress) + } catch (e: Exception) { + if (isRunning) { + Log.e(TAG, "Error receiving packet: ${e.message}, recreating socket...") + try { + socket?.close() + } catch (_: Exception) {} + socket = null + delay(2000) + } } - } catch (e: Exception) { - Log.e(TAG, "Socket creation failed: ${e.message}") } } } @@ -249,11 +261,10 @@ object UDPDiscoveryManager { handlePresenceMessage(context, json, sourceIp) // Optimization: If we receive a presence packet in PASSIVE mode, - // we might want to respond once so the Mac knows we are here, - // essentially performing a "lazy handshake" + // we respond so the Mac knows we are here (lazy handshake) if (currentMode == DiscoveryMode.PASSIVE && isDiscoveryEnabled) { CoroutineScope(Dispatchers.IO).launch { - // broadcastPresence(context) // Optional: avoid if we want to be truly silent + broadcastPresence(context) } } } From eb1edcd4edb9875d5ee4c2c4f6152b21c2fec40d Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 23:46:49 +0530 Subject: [PATCH 31/33] feat: add BLE volume controls and remove album art from media state payload to optimize bandwidth --- .../airsync/data/ble/BleTransportBridge.kt | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 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 1d2ecc8c..7afb3c88 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 @@ -47,7 +47,7 @@ object BleTransportBridge { audio.volume.toString(), if (audio.isMuted) "1" else "0", audio.likeStatus, - audio.albumArtLite ?: "" + "" // Avoid sending heavy base64 art over BLE to conserve bandwidth ).joinToString(BleConstants.DELIMITER) gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) @@ -79,12 +79,24 @@ object BleTransportBridge { fun handleMediaControl(action: String, context: android.content.Context) { Log.d(TAG, "Media control from BLE: $action") - when (action) { - "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) - "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) - "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) - "callAccept" -> CallControlUtil.acceptCall(context) - "callDecline", "callEnd" -> CallControlUtil.endCall(context) + when { + action == "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) + action == "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) + action == "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) + action == "callAccept" -> CallControlUtil.acceptCall(context) + action == "callDecline" || action == "callEnd" -> CallControlUtil.endCall(context) + + // Volume Controls over BLE + action == "volumeUp" -> com.sameerasw.airsync.utils.VolumeControlUtil.increaseVolume(context) + action == "volumeDown" -> com.sameerasw.airsync.utils.VolumeControlUtil.decreaseVolume(context) + action == "muteToggle" -> com.sameerasw.airsync.utils.VolumeControlUtil.toggleMute(context) + action.startsWith("setVolume|") -> { + val volStr = action.substringAfter("setVolume|") + val vol = volStr.toIntOrNull() + if (vol != null && vol in 0..100) { + com.sameerasw.airsync.utils.VolumeControlUtil.setVolume(context, vol) + } + } } } From 3ca11a328f43123f41bd1a94ba4ba009963d397e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 26 May 2026 23:51:29 +0530 Subject: [PATCH 32/33] feat: implement BLE command handler to toggle notification app preferences via DataStore --- .../airsync/data/ble/BleTransportBridge.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 7afb3c88..07fe68f3 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 @@ -6,6 +6,8 @@ import com.sameerasw.airsync.domain.model.AudioInfo import java.security.MessageDigest import java.util.* import com.sameerasw.airsync.utils.CallControlUtil +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch object BleTransportBridge { private const val TAG = "BleTransportBridge" @@ -97,6 +99,46 @@ object BleTransportBridge { com.sameerasw.airsync.utils.VolumeControlUtil.setVolume(context, vol) } } + action.startsWith("toggleNotif|") -> { + val parts = action.split("|") + if (parts.size >= 3) { + val pkg = parts[1] + val state = parts[2] == "true" + Log.d(TAG, "Received toggleAppNotif via BLE: pkg=$pkg, state=$state") + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + val dataStoreManager = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val currentApps = dataStoreManager.getNotificationApps().first().toMutableList() + val idx = currentApps.indexOfFirst { it.packageName == pkg } + if (idx != -1) { + currentApps[idx] = currentApps[idx].copy(isEnabled = state, lastUpdated = System.currentTimeMillis()) + dataStoreManager.saveNotificationApps(currentApps) + Log.d(TAG, "Successfully toggled app notification preference via BLE for $pkg to $state") + } else { + val isSystemApp = try { + val applicationInfo = context.packageManager.getApplicationInfo(pkg, 0) + (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 + } catch (_: Exception) { + false + } + val newApp = com.sameerasw.airsync.domain.model.NotificationApp( + packageName = pkg, + appName = pkg, + isEnabled = state, + isSystemApp = isSystemApp, + lastUpdated = System.currentTimeMillis() + ) + currentApps.add(newApp) + dataStoreManager.saveNotificationApps(currentApps) + Log.d(TAG, "Saved new app notification preference via BLE for $pkg to $state") + } + com.sameerasw.airsync.utils.SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) + } catch (e: Exception) { + Log.e(TAG, "Error toggling app notification preference via BLE: ${e.message}") + } + } + } + } } } From 9e378b56dd34fb46bc5bc8c31ca40c982c86f5f0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 27 May 2026 00:14:17 +0530 Subject: [PATCH 33/33] chore: bump app version to 4.0.0 and update minimum Mac app version requirement --- app/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfa73fda..23d0390c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { defaultConfig { applicationId = "com.sameerasw.airsync" minSdk = 30 - versionCode = 28 - versionName = "3.2.0" + versionCode = 29 + versionName = "4.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -62,7 +62,7 @@ kotlin { defaultConfig { targetSdk = 37 - buildConfigField("String", "MIN_MAC_APP_VERSION", "\"3.0.0\"") + buildConfigField("String", "MIN_MAC_APP_VERSION", "\"4.0.0\"") } }