Skip to content

Commit b451320

Browse files
authored
Merge pull request #113 from sameerasw/develop
Develop - BLE, UI generalization, Seekbar, WebDAV, Refactor and AGP upgrades, Native call controls, Notifying app picker, background discovery and more
2 parents cf4c625 + 9e378b5 commit b451320

76 files changed

Lines changed: 4023 additions & 1344 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ app/release
3535
local.properties
3636
.vscode/launch.json
3737
build/reports/problems/problems-report.html
38+
.agents/

app/build.gradle.kts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@ plugins {
1111

1212
android {
1313
namespace = "com.sameerasw.airsync"
14-
compileSdk = 36
14+
compileSdk = 37
1515

1616
defaultConfig {
1717
applicationId = "com.sameerasw.airsync"
1818
minSdk = 30
19-
targetSdk = 36
20-
versionCode = 27
21-
versionName = "3.1.0"
19+
versionCode = 29
20+
versionName = "4.0.0"
2221

2322
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2423
}
@@ -47,21 +46,23 @@ android {
4746
}
4847
}
4948
compileOptions {
50-
sourceCompatibility = VERSION_11
51-
targetCompatibility = VERSION_17
49+
sourceCompatibility = JavaVersion.VERSION_21
50+
targetCompatibility = JavaVersion.VERSION_21
5251
}
5352
kotlin {
5453
compilerOptions {
55-
jvmTarget.set(JvmTarget.JVM_17)
54+
jvmTarget.set(JvmTarget.JVM_21)
5655
}
5756
}
5857
buildFeatures {
5958
compose = true
6059
buildConfig = true
6160
}
61+
compileSdkMinor = 0
6262

6363
defaultConfig {
64-
buildConfigField("String", "MIN_MAC_APP_VERSION", "\"3.0.0\"")
64+
targetSdk = 37
65+
buildConfigField("String", "MIN_MAC_APP_VERSION", "\"4.0.0\"")
6566
}
6667
}
6768

@@ -154,6 +155,14 @@ dependencies {
154155

155156
implementation(libs.wire.runtime)
156157
implementation(libs.bouncycastle)
158+
159+
// Ktor Server for WebDAV
160+
implementation(libs.ktor.server.core)
161+
implementation(libs.ktor.server.cio)
162+
implementation(libs.ktor.server.host.common)
163+
implementation(libs.ktor.server.status.pages)
164+
implementation(libs.ktor.server.content.negotiation)
165+
implementation(libs.ktor.serialization.gson)
157166
}
158167

159168
wire {

app/proguard-rules.pro

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@
2929
-keep class com.sameerasw.airsync.domain.model.** { *; }
3030

3131
# Data Layer
32-
-keep class com.sameerasw.airsync.data.** { *; }
32+
-keep class com.sameerasw.airsync.data.** { *; }
33+
34+
# Ktor & SLF4J missing classes on Android
35+
-dontwarn java.lang.management.ManagementFactory
36+
-dontwarn java.lang.management.RuntimeMXBean
37+
-dontwarn org.slf4j.impl.StaticLoggerBinder

app/src/main/AndroidManifest.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
1313
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1414
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
15+
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
16+
17+
<uses-permission android:name="android.permission.BLUETOOTH" />
18+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
19+
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
20+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
21+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
22+
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
1523

1624
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
1725
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
@@ -38,6 +46,7 @@
3846
<uses-permission android:name="android.permission.READ_CALL_LOG" />
3947
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
4048
<uses-permission android:name="android.permission.READ_CONTACTS" />
49+
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
4150

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

app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@ import kotlinx.coroutines.runBlocking
1010

1111
class AirSyncApp : Application() {
1212
private var activityCount = 0
13+
private lateinit var bleConnectionManager: com.sameerasw.airsync.data.ble.BleConnectionManager
1314

1415
companion object {
1516
private var instance: AirSyncApp? = null
1617
fun isAppForeground(): Boolean = instance?.isForeground() ?: false
18+
fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = instance?.bleConnectionManager
1719
}
1820

1921
override fun onCreate() {
2022
super.onCreate()
2123
instance = this
2224
initSentry()
25+
26+
bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this)
27+
bleConnectionManager.start()
2328
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
2429
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
2530
override fun onActivityStarted(activity: Activity) {

app/src/main/java/com/sameerasw/airsync/MainActivity.kt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import com.sameerasw.airsync.utils.KeyguardHelper
6060
import com.sameerasw.airsync.utils.NotesRoleManager
6161
import com.sameerasw.airsync.utils.PermissionUtil
6262
import com.sameerasw.airsync.utils.ShortcutUtil
63+
import com.sameerasw.airsync.utils.UDPDiscoveryManager
6364
import com.sameerasw.airsync.utils.WebSocketUtil
6465
import kotlinx.coroutines.flow.first
6566
import kotlinx.coroutines.runBlocking
@@ -339,8 +340,12 @@ class MainActivity : ComponentActivity() {
339340
handleNotesRoleIntent(intent)
340341

341342
// Start ADB discovery once at app startup and keep it running
342-
AdbDiscoveryHolder.initialize(this)
343-
Log.d("MainActivity", "Started persistent ADB discovery at app startup")
343+
if (PermissionUtil.isLocalNetworkPermissionGranted(this)) {
344+
AdbDiscoveryHolder.initialize(this)
345+
Log.d("MainActivity", "Started persistent ADB discovery at app startup")
346+
} else {
347+
Log.d("MainActivity", "Skipping persistent ADB discovery at startup: ACCESS_LOCAL_NETWORK permission not granted")
348+
}
344349

345350
// Check if this is a QS tile long-press intent and device is not connected
346351
if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") {
@@ -581,11 +586,26 @@ class MainActivity : ComponentActivity() {
581586
}
582587
}
583588

589+
override fun onResume() {
590+
super.onResume()
591+
if (PermissionUtil.isLocalNetworkPermissionGranted(this)) {
592+
AdbDiscoveryHolder.initialize(this)
593+
val ds = DataStoreManager.getInstance(applicationContext)
594+
val isDiscoveryEnabled = runBlocking {
595+
ds.getDeviceDiscoveryEnabled().first()
596+
}
597+
UDPDiscoveryManager.start(this, isDiscoveryEnabled)
598+
UDPDiscoveryManager.burstBroadcast(this)
599+
}
600+
}
601+
584602
/**
585603
* Ensure ADB discovery is running (started at app startup, this just verifies it's active).
586604
*/
587605
fun initializeAdbDiscovery() {
588-
AdbDiscoveryHolder.initialize(this)
606+
if (PermissionUtil.isLocalNetworkPermissionGranted(this)) {
607+
AdbDiscoveryHolder.initialize(this)
608+
}
589609
}
590610

591611
/**
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.sameerasw.airsync.data.ble
2+
3+
import android.util.Log
4+
5+
object BleChunkUtil {
6+
private const val TAG = "BleChunkUtil"
7+
8+
/**
9+
* Splits a string payload into chunks suitable for BLE transmission.
10+
* Each chunk starts with a 2-byte header: [currentIndex, totalChunks]
11+
*/
12+
fun splitIntoChunks(payload: String, mtu: Int): List<ByteArray> {
13+
val data = payload.toByteArray(Charsets.UTF_8)
14+
val maxPayloadSize = mtu - BleConstants.CHUNK_HEADER_SIZE
15+
16+
if (maxPayloadSize <= 0) {
17+
Log.e(TAG, "MTU too small: $mtu")
18+
return emptyList()
19+
}
20+
21+
val totalChunks = (data.size + maxPayloadSize - 1) / maxPayloadSize
22+
val chunks = mutableListOf<ByteArray>()
23+
24+
for (i in 0 until totalChunks) {
25+
val start = i * maxPayloadSize
26+
val end = minOf(start + maxPayloadSize, data.size)
27+
val chunkData = data.sliceArray(start until end)
28+
29+
val chunk = ByteArray(BleConstants.CHUNK_HEADER_SIZE + chunkData.size)
30+
val buffer = java.nio.ByteBuffer.wrap(chunk)
31+
buffer.putShort(i.toShort())
32+
buffer.putShort(totalChunks.toShort())
33+
34+
chunkData.copyInto(chunk, BleConstants.CHUNK_HEADER_SIZE)
35+
chunks.add(chunk)
36+
}
37+
38+
return chunks
39+
}
40+
41+
/**
42+
* Reassembles chunks into the original string.
43+
* Expects a map of index to chunk data (without the header).
44+
*/
45+
fun reassemble(chunks: Map<Int, ByteArray>): String {
46+
val sortedIndices = chunks.keys.sorted()
47+
if (sortedIndices.isEmpty()) return ""
48+
49+
val totalSize = chunks.values.sumOf { it.size }
50+
val result = ByteArray(totalSize)
51+
52+
var offset = 0
53+
for (index in sortedIndices) {
54+
val chunk = chunks[index] ?: continue
55+
chunk.copyInto(result, offset)
56+
offset += chunk.size
57+
}
58+
59+
return String(result, Charsets.UTF_8)
60+
}
61+
62+
/**
63+
* Extracts header information from a raw BLE packet.
64+
*/
65+
fun parseHeader(packet: ByteArray): Pair<Int, Int>? {
66+
if (packet.size < BleConstants.CHUNK_HEADER_SIZE) return null
67+
val buffer = java.nio.ByteBuffer.wrap(packet)
68+
val current = buffer.short.toInt() and 0xFFFF
69+
val total = buffer.short.toInt() and 0xFFFF
70+
return Pair(current, total)
71+
}
72+
73+
/**
74+
* Extracts payload data from a raw BLE packet (strips header).
75+
*/
76+
fun getPayload(packet: ByteArray): ByteArray {
77+
if (packet.size <= BleConstants.CHUNK_HEADER_SIZE) return byteArrayOf()
78+
return packet.sliceArray(BleConstants.CHUNK_HEADER_SIZE until packet.size)
79+
}
80+
81+
/**
82+
* Helper class to reassemble chunks as they arrive.
83+
*/
84+
class Reassembler {
85+
private val chunks = mutableMapOf<Int, ByteArray>()
86+
private var totalChunks = -1
87+
88+
fun addChunk(packet: ByteArray): String? {
89+
val header = parseHeader(packet) ?: return null
90+
val current = header.first
91+
val total = header.second
92+
93+
if (totalChunks != -1 && totalChunks != total) {
94+
// New transmission started or mismatch, reset
95+
chunks.clear()
96+
}
97+
totalChunks = total
98+
99+
chunks[current] = getPayload(packet)
100+
101+
if (chunks.size == totalChunks) {
102+
val result = reassemble(chunks)
103+
chunks.clear()
104+
totalChunks = -1
105+
return result
106+
}
107+
return null
108+
}
109+
110+
fun clear() {
111+
chunks.clear()
112+
totalChunks = -1
113+
}
114+
}
115+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.sameerasw.airsync.data.ble
2+
3+
import android.content.Context
4+
import android.util.Log
5+
import com.sameerasw.airsync.data.local.DataStoreManager
6+
import com.sameerasw.airsync.utils.WebSocketUtil
7+
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.flow.collectLatest
9+
import kotlinx.coroutines.flow.combine
10+
import kotlinx.coroutines.flow.flatMapLatest
11+
12+
class BleConnectionManager(private val context: Context) {
13+
companion object {
14+
private const val TAG = "BleConnectionManager"
15+
}
16+
17+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
18+
private val dataStoreManager = DataStoreManager(context)
19+
private var bleServer: BleGattServer? = null
20+
21+
private var isBleEnabled = false
22+
23+
@OptIn(ExperimentalCoroutinesApi::class)
24+
private val _serverFlow = kotlinx.coroutines.flow.MutableStateFlow<BleGattServer?>(null)
25+
26+
@OptIn(ExperimentalCoroutinesApi::class)
27+
val connectionState = _serverFlow.flatMapLatest { server ->
28+
server?.connectionState ?: kotlinx.coroutines.flow.MutableStateFlow(BleGattServer.BleConnectionState.DISCONNECTED)
29+
}
30+
31+
fun start() {
32+
if (bleServer == null) {
33+
bleServer = BleGattServer(context)
34+
_serverFlow.value = bleServer
35+
BleTransportBridge.initialize(bleServer!!)
36+
}
37+
38+
scope.launch {
39+
combine(
40+
dataStoreManager.getBleSyncEnabled(),
41+
dataStoreManager.getBleAutoConnectEnabled(),
42+
WebSocketUtil.connectionState
43+
) { enabled, auto, wsConnected ->
44+
Triple(enabled, auto, wsConnected)
45+
}.collectLatest { (enabled, _, wsConnected) ->
46+
isBleEnabled = enabled
47+
updateBleState(regularConnectionActive = wsConnected)
48+
}
49+
}
50+
}
51+
52+
private fun updateBleState(regularConnectionActive: Boolean) {
53+
if (!isBleEnabled) {
54+
Log.d(TAG, "BLE disabled, stopping server")
55+
bleServer?.stop()
56+
return
57+
}
58+
59+
if (regularConnectionActive) {
60+
// Regular Wi-Fi/USB connection is up — pause advertising to save power.
61+
Log.d(TAG, "Regular connection active — pausing BLE advertising")
62+
bleServer?.pauseAdvertising()
63+
} else {
64+
// No regular connection — ensure server is started and advertising.
65+
Log.d(TAG, "No regular connection — resuming BLE advertising")
66+
bleServer?.start()
67+
bleServer?.resumeAdvertising()
68+
}
69+
}
70+
71+
fun stop() {
72+
scope.cancel()
73+
bleServer?.stop()
74+
}
75+
76+
val isAuthenticated: Boolean
77+
get() = bleServer?.isAuthenticated ?: false
78+
79+
fun sendChunkedNotification(characteristicUuid: java.util.UUID, payload: String) {
80+
bleServer?.sendChunkedNotification(characteristicUuid, payload)
81+
}
82+
83+
fun sendNotification(characteristicUuid: java.util.UUID, data: ByteArray) {
84+
bleServer?.sendNotification(characteristicUuid, data)
85+
}
86+
87+
fun disconnectAllConnectedDevices() {
88+
bleServer?.disconnectAllConnectedDevices()
89+
}
90+
}

0 commit comments

Comments
 (0)