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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

<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" />
<uses-permission android:name="android.permission.READ_WALLPAPER_INTERNAL" />
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
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,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<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, 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)
}
}
50 changes: 50 additions & 0 deletions app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt
Original file line number Diff line number Diff line change
@@ -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"
}
Loading