Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ app/release
local.properties
.vscode/launch.json
build/reports/problems/problems-report.html
.agents/
.agents/
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
<service
android:name=".service.MediaNotificationListener"
android:exported="false"
android:directBootAware="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
Expand Down Expand Up @@ -192,6 +193,7 @@
<service
android:name=".service.AirSyncService"
android:exported="false"
android:directBootAware="true"
android:foregroundServiceType="connectedDevice" />

<service
Expand Down
92 changes: 74 additions & 18 deletions app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class BleGattServer(private val context: Context) {
private val connectedDevices = mutableSetOf<BluetoothDevice>()
private val characteristicQueues = mutableMapOf<UUID, ConcurrentLinkedQueue<ByteArray>>()
private val isSending = mutableMapOf<UUID, Boolean>()
private val preparedWrites = java.util.concurrent.ConcurrentHashMap<String, java.io.ByteArrayOutputStream>()

var isAuthenticated = false
private set
Expand Down Expand Up @@ -280,7 +281,6 @@ class BleGattServer(private val context: Context) {
fun resumeAdvertising() {
if (!isAdvertisingPaused) return
if (gattServer == null) return
if (_connectionState.value == BleConnectionState.DISCONNECTED) return
Log.d(TAG, "BLE advertising resumed")
isAdvertisingPaused = false
startAdvertising()
Expand Down Expand Up @@ -308,6 +308,7 @@ class BleGattServer(private val context: Context) {
Log.d(TAG, "Device connected: ${device.address}")
connectedDevices.add(device)
_connectionState.value = BleConnectionState.CONNECTED
stopAdvertising()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "Device disconnected: ${device.address}")
connectedDevices.remove(device)
Expand Down Expand Up @@ -376,8 +377,7 @@ class BleGattServer(private val context: Context) {
offset: Int,
value: ByteArray
) {
Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}")

Log.d(TAG, "Write request for characteristic=${characteristic.uuid}, fromDevice=${device.address}, requestId=$requestId, preparedWrite=$preparedWrite, responseNeeded=$responseNeeded, offset=$offset, valueLength=${value.size}")
if (characteristic.uuid != BleConstants.CHAR_AUTH_TOKEN && !isAuthenticated) {
Log.w(
TAG,
Expand All @@ -395,6 +395,15 @@ class BleGattServer(private val context: Context) {
return
}

if (preparedWrite) {
val key = "${device.address}_${characteristic.uuid}"
val bos = preparedWrites.getOrPut(key) { java.io.ByteArrayOutputStream() }
bos.write(value)
if (responseNeeded) {
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
}
return
}
when (characteristic.uuid) {
BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value)
BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value)
Expand Down Expand Up @@ -504,25 +513,75 @@ class BleGattServer(private val context: Context) {
// This is crucial for sequential chunk sending
processNextInQueues()
}

override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) {
Log.d(TAG, "onExecuteWrite: device=${device.address}, requestId=$requestId, execute=$execute")
if (execute) {
val keys = preparedWrites.keys().toList()
for (key in keys) {
if (key.startsWith(device.address)) {
val bos = preparedWrites.remove(key) ?: continue
val value = bos.toByteArray()
val uuidStr = key.substring(device.address.length + 1)
val uuid = UUID.fromString(uuidStr)
val characteristic = findCharacteristic(uuid)
if (characteristic != null) {
Log.d(TAG, "Executing prepared write for characteristic=$uuid, valueLength=${value.size}")
scope.launch {
when (uuid) {
BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value)
BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value)
BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) }
BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) }
BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(uuid, value) { handleMacMediaState(it) }
BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(uuid, value) {
Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}")
ClipboardSyncManager.handleClipboardUpdate(context, it)
}
BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(uuid, value) {
Log.d(TAG, "Received Mac Device Name: $it")
MacDeviceStatusManager.updateMacStatus(context, name = it)
}
BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(uuid, value) { handleNotificationDismiss(it) }
}
}
}
}
}
} else {
val keys = preparedWrites.keys().toList()
for (key in keys) {
if (key.startsWith(device.address)) {
preparedWrites.remove(key)
}
}
}
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}

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

Log.d(TAG, "Handling auth request from ${device.address}. DB device details: name=${deviceData?.name}, ip=${deviceData?.ipAddress}, port=${deviceData?.port}, storedKeyExists=${storedKey != null}")
if (storedKey != null) {
Log.d(TAG, "Stored symmetric key found: $storedKey")
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))) {
val expectedTokenBytes = expectedToken.toByteArray(Charsets.UTF_8)
val expectedTokenBase64 = android.util.Base64.encodeToString(expectedTokenBytes, android.util.Base64.NO_WRAP)
val receivedTokenBase64 = android.util.Base64.encodeToString(token, android.util.Base64.NO_WRAP)

Log.d(TAG, "Expected token string: '$expectedToken'")
Log.d(TAG, "Expected token base64: $expectedTokenBase64")
Log.d(TAG, "Received token string: '$receivedTokenStr'")
Log.d(TAG, "Received token base64: $receivedTokenBase64")

val isMatch = token.contentEquals(expectedTokenBytes)
Log.d(TAG, "Performing byte-by-byte authentication token comparison. Match result: $isMatch")

if (isMatch) {
Log.i(TAG, "BLE Auth Success!")
isAuthenticated = true
_connectionState.value = BleConnectionState.AUTHENTICATED
Expand All @@ -533,11 +592,8 @@ class BleGattServer(private val context: Context) {
BleTransportBridge.sendDeviceName()
startHeartbeat()
} else {
Log.w(TAG, "BLE Auth Failed! Token mismatch.")
sendNotification(
BleConstants.CHAR_AUTH_RESULT,
byteArrayOf(BleConstants.AUTH_FAILED)
)
Log.w(TAG, "BLE Auth Failed! Token mismatch (byte-level 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.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ object BleTransportBridge {
return try {
val md = MessageDigest.getInstance("SHA-256")
val hash = md.digest(symmetricKey.toByteArray(Charsets.UTF_8))
Base64.getEncoder().encodeToString(hash.copyOf(16))
Base64.getEncoder().encodeToString(hash.copyOf(12))
} catch (e: Exception) {
Log.e(TAG, "Error deriving auth token: ${e.message}")
""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ fun AirSyncMainScreen(

if (!uiState.isOnboardingCompleted) {
hasSeenWelcomeThisSession = true
} else {
hasSeenWelcomeThisSession = false
}

// Volume & Media state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class AirSyncViewModel(
) : ViewModel() {

companion object {
private const val TAG = "AirSyncViewModel"
fun create(context: Context): AirSyncViewModel {
val dataStoreManager = DataStoreManager(context)
val repository = AirSyncRepositoryImpl(dataStoreManager)
Expand Down Expand Up @@ -131,6 +132,13 @@ class AirSyncViewModel(
}

init {
// Clear manual disconnect flag on app startup so auto-reconnect works
viewModelScope.launch {
try {
repository.setUserManuallyDisconnected(false)
} catch (_: Exception) {}
}

// Register for WebSocket connection status updates
WebSocketUtil.registerConnectionStatusListener(connectionStatusListener)
try {
Expand Down Expand Up @@ -229,6 +237,9 @@ class AirSyncViewModel(
appContext?.unregisterReceiver(powerSaveReceiver)
} catch (_: IllegalArgumentException) {
// Receiver was not registered
} catch (e: Exception) {
// Context may be invalid (Activity leaked)
Log.e(TAG, "Failed to unregister receiver: ${e.message}")
}
}

Expand Down Expand Up @@ -768,7 +779,7 @@ class AirSyncViewModel(
fun startNetworkMonitoring(context: Context) {
if (isNetworkMonitoringActive) return
isNetworkMonitoringActive = true
previousNetworkIp = DeviceInfoUtil.getWifiIpAddress(context) ?: "Unknown"
previousNetworkIp = DeviceInfoUtil.getWifiIpAddress(context) ?: DeviceInfoUtil.getLocalIpAddress() ?: "Unknown"

viewModelScope.launch {
try {
Expand Down Expand Up @@ -799,13 +810,16 @@ class AirSyncViewModel(
if (currentIp == "No Wi-Fi" || currentIp == "Unknown") {
// No usable Wi‑Fi: ensure we stop any active connection and do not attempt reconnect
try {
WebSocketUtil.disconnect(context)
WebSocketUtil.disconnect(context, isManual = false)
} catch (_: Exception) {
}
// Stop service if needed
ServiceManager.updateServiceState(context)
_uiState.value =
_uiState.value.copy(isConnected = false, isConnecting = false)
_uiState.value.copy(
isConnected = com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated(),
isConnecting = false
)
return@collect
} else {
// Ensure service state is updated
Expand All @@ -822,7 +836,7 @@ class AirSyncViewModel(
// If connected/connecting to old network, disconnect first to force a clean switch
if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) {
try {
WebSocketUtil.disconnect(context)
WebSocketUtil.disconnect(context, isManual = false)
} catch (_: Exception) {
}
}
Expand Down Expand Up @@ -881,7 +895,7 @@ class AirSyncViewModel(
// No mapping for this network: disconnect if connected and, if allowed, start generic auto-reconnect
if (WebSocketUtil.isConnected() || WebSocketUtil.isConnecting()) {
try {
WebSocketUtil.disconnect(context)
WebSocketUtil.disconnect(context, isManual = false)
} catch (_: Exception) {
}
}
Expand Down
Loading