Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
fac1ca0
feat: add support for playback duration, elapsed time, and rate to me…
sameerasw May 13, 2026
7e6867a
feat: implement BLE support
sameerasw May 15, 2026
ee301f4
feat: update BLE status labels and move BleSyncCard to a new Bluetoot…
sameerasw May 15, 2026
b36eb79
chore: ignore .agents directory and update BLE heartbeat interval and…
sameerasw May 16, 2026
4f1875c
feat: send battery heartbeat over BLE and optimize media state update…
sameerasw May 16, 2026
edc52d0
refactor: centralize BLE transport logic into WebSocketUtil and use c…
sameerasw May 16, 2026
b5c86e2
feat: include app name parameter in notification payload transmission
sameerasw May 16, 2026
832f56e
feat: add albumArtLite support for optimized BLE media state transmis…
sameerasw May 16, 2026
e383a94
feat: move auto-reconnect toggle to a new dedicated ConnectionSetting…
sameerasw May 19, 2026
113eac1
refactor: Combine BLE settings to one toggle
sameerasw May 19, 2026
6066017
Merge pull request #108 from sameerasw/ble
sameerasw May 19, 2026
f69e728
feat: dynamically update Bluetooth adapter name
sameerasw May 19, 2026
4a11814
feat: implement manual BLE disconnect signal and device cleanup durin…
sameerasw May 19, 2026
26a901c
feat: add toggle checks for discovery BLE authentication requirements
sameerasw May 19, 2026
95beca5
refactor: standardize UI card components with updated iconography, st…
sameerasw May 19, 2026
2c64c99
feat: media progress bar
sameerasw May 19, 2026
4da51ab
feat: Disable BLE while regular conneciton active
sameerasw May 20, 2026
0c6fc27
feat: WebDAV for remote file browsing
sameerasw May 21, 2026
4fd6376
feat: WebDAV toggle and plus license handling
sameerasw May 21, 2026
db03d64
Merge pull request #111 from sameerasw/webDAV
sameerasw May 21, 2026
d7f97fd
feat: Implement Seekbar sync for now playing [2/2]
Mudit200408 May 21, 2026
4803b7a
Merge pull request #109 from Mudit200408/develop
sameerasw May 21, 2026
1157aac
build: upgrade Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9…
sameerasw May 21, 2026
098e2bc
feat: add support for ACCESS_LOCAL_NETWORK permission and update targ…
sameerasw May 21, 2026
86b6223
build: upgrade source and target compatibility to Java 21
sameerasw May 21, 2026
9e29217
chore: add Ktor proguard rules and configure foojay toolchain resolver
sameerasw May 21, 2026
5f68348
feat: implement websocket-based call control
sameerasw May 22, 2026
02f7c6d
feat: initialize MediaSession in onCreate and ensure foreground servi…
sameerasw May 26, 2026
c901bfc
feat: add in-app notification app selection
sameerasw May 26, 2026
607d4d5
feat: update formatTime to support hour display in FloatingMediaPlayer
sameerasw May 26, 2026
9805f17
feat: add connection state check to prevent sync and notification eve…
sameerasw May 26, 2026
344f1fb
refactor: prevent redundant network updates in NetworkMonitor by trac…
sameerasw May 26, 2026
071fcb3
feat: Passive background discovery
sameerasw May 26, 2026
eb1edcd
feat: add BLE volume controls and remove album art from media state p…
sameerasw May 26, 2026
3ca11a3
feat: implement BLE command handler to toggle notification app prefer…
sameerasw May 26, 2026
9e378b5
chore: bump app version to 4.0.0 and update minimum Mac app version r…
sameerasw May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ app/release
local.properties
.vscode/launch.json
build/reports/problems/problems-report.html
.agents/
25 changes: 17 additions & 8 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ 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"
versionCode = 29
versionName = "4.0.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -47,21 +46,23 @@ 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 {
compose = true
buildConfig = true
}
compileSdkMinor = 0

defaultConfig {
buildConfigField("String", "MIN_MAC_APP_VERSION", "\"3.0.0\"")
targetSdk = 37
buildConfigField("String", "MIN_MAC_APP_VERSION", "\"4.0.0\"")
}
}

Expand Down Expand Up @@ -154,6 +155,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 {
Expand Down
7 changes: 6 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@
-keep class com.sameerasw.airsync.domain.model.** { *; }

# Data Layer
-keep class com.sameerasw.airsync.data.** { *; }
-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
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
Expand All @@ -38,6 +46,7 @@
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />

<uses-permission android:name="com.sameerasw.permission.ESSENTIALS_AIRSYNC_BRIDGE" />

Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 23 additions & 3 deletions app/src/main/java/com/sameerasw/airsync/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
}
}

/**
Expand Down
115 changes: 115 additions & 0 deletions app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt
Original file line number Diff line number Diff line change
@@ -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<ByteArray> {
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<ByteArray>()

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<Int, ByteArray>): 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<Int, Int>? {
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<Int, ByteArray>()
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
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<BleGattServer?>(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, wsConnected ->
Triple(enabled, auto, wsConnected)
}.collectLatest { (enabled, _, wsConnected) ->
isBleEnabled = enabled
updateBleState(regularConnectionActive = wsConnected)
}
}
}

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()
}
}

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)
}

fun disconnectAllConnectedDevices() {
bleServer?.disconnectAllConnectedDevices()
}
}
Loading