Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f6f2f42
feat: AirBridge relay support and LAN authentication
tornado-bunk Mar 14, 2026
556f7bc
feat: Improved LAN reconnection from relay and connection transport UI
tornado-bunk Mar 14, 2026
d419508
Merge branch 'main' into feat/airbridge-server - resolve Android conf…
tornado-bunk Mar 15, 2026
4602eb3
Remove custom HMAC auth, use v3.0.0 native encryption
tornado-bunk Mar 15, 2026
1bf31c3
feat: WebSocket resilience, lightweight keepalive, and plaintext fall…
tornado-bunk Mar 15, 2026
97c4c94
refactor: Floating toolbar animations and CryptoUtil cleanup
tornado-bunk Mar 15, 2026
1cb46ed
feat: Externalize expanded state in AirSyncFloatingToolbar
tornado-bunk Mar 15, 2026
cdaee74
fix: no floatingdock
tornado-bunk Mar 15, 2026
f8021fc
fix: FAB visibility logic on scroll
tornado-bunk Mar 15, 2026
ca24141
feat: improve relay and LAN reconnection logic
tornado-bunk Mar 16, 2026
ae92280
feat: Added peer status monitoring and Mac wake handling for AirBridge
tornado-bunk Mar 16, 2026
c1a00fb
feat: Peer health status badge
tornado-bunk Mar 16, 2026
b5eec36
feat: Peer transport notification for seamless UI switching
tornado-bunk Mar 16, 2026
6751f10
feat: Improved transport synchronization and QuickShare foreground di…
tornado-bunk Mar 16, 2026
9af2bcc
feat: Enhance macWake handling with LAN reconnect retry logic
tornado-bunk Mar 17, 2026
0328afb
feat: Implement adaptive LAN-first probing for relay connections
tornado-bunk Mar 18, 2026
8999d8f
feat: bootstrap LAN-first probing on app restart with active relay
tornado-bunk Mar 19, 2026
6c32f1d
feat: Enhance transport offer handling and candidate extraction for L…
tornado-bunk Mar 19, 2026
4653206
feat: Simplify relay connection label and peer health indicator
tornado-bunk Mar 21, 2026
8321deb
feat: Implement HMAC-SHA256 challenge-response authentication for relay
tornado-bunk Mar 25, 2026
4c98c1b
chore: simplify query parameter parsing logic
tornado-bunk Mar 26, 2026
94e4be3
feat: Refine AirSyncMainScreen UI and URI parsing
tornado-bunk Mar 26, 2026
2cfb4fe
clean: Refactor and optimize logging for AirBridge and transport sync…
tornado-bunk Mar 26, 2026
37e3004
feat: Mark AirBridge Relay as Beta in UI
tornado-bunk Mar 26, 2026
147a6a6
feat: readd URLDecoder import to MainActivity
tornado-bunk Mar 26, 2026
13eae8a
readded removed logs
tornado-bunk Mar 26, 2026
e49fb1b
revert: reverted logging for WebSocket decryption failures
tornado-bunk Mar 30, 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
22 changes: 22 additions & 0 deletions app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import android.app.Activity
import android.app.Application
import android.os.Bundle
import com.sameerasw.airsync.data.local.DataStoreManager
import com.sameerasw.airsync.utils.AirBridgeClient
import com.sameerasw.airsync.utils.WebSocketMessageHandler
import io.sentry.android.core.SentryAndroid
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

class AirSyncApp : Application() {
Expand All @@ -20,6 +25,7 @@ class AirSyncApp : Application() {
super.onCreate()
instance = this
initSentry()
initAirBridge()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {
Expand Down Expand Up @@ -48,4 +54,20 @@ class AirSyncApp : Application() {
options.isEnabled = true
}
}

private fun initAirBridge() {
// Wire message handler: relay messages → existing WebSocket message pipeline
AirBridgeClient.setMessageHandler { context, message ->
WebSocketMessageHandler.handleIncomingMessage(context, message)
}

// Auto-connect if previously enabled
CoroutineScope(Dispatchers.IO).launch {
val ds = DataStoreManager.getInstance(this@AirSyncApp)
val enabled = ds.getAirBridgeEnabled().first()
if (enabled) {
AirBridgeClient.connect(this@AirSyncApp)
}
}
}
}
68 changes: 57 additions & 11 deletions app/src/main/java/com/sameerasw/airsync/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
Expand All @@ -54,13 +55,15 @@ import com.sameerasw.airsync.presentation.ui.screens.AirSyncMainScreen
import com.sameerasw.airsync.ui.theme.AirSyncTheme
import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
import com.sameerasw.airsync.utils.AdbMdnsDiscovery
import com.sameerasw.airsync.utils.AirBridgeClient
import com.sameerasw.airsync.utils.ContentCaptureManager
import com.sameerasw.airsync.utils.DevicePreviewResolver
import com.sameerasw.airsync.utils.KeyguardHelper
import com.sameerasw.airsync.utils.NotesRoleManager
import com.sameerasw.airsync.utils.PermissionUtil
import com.sameerasw.airsync.utils.WebSocketUtil
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.net.URLDecoder

Expand Down Expand Up @@ -348,21 +351,48 @@ class MainActivity : ComponentActivity() {
var pcName: String? = null
var isPlus = false
var symmetricKey: String? = null
var relayUrl: String? = null
var airBridgePairingId: String? = null
var airBridgeSecret: String? = null

data?.let { uri ->
val urlString = uri.toString()
val queryPart = urlString.substringAfter('?', "")
if (queryPart.isNotEmpty()) {
val params = queryPart.split('?')
val paramMap = params.associate {
val parts = it.split('=', limit = 2)
val key = parts.getOrNull(0) ?: ""
val value = parts.getOrNull(1) ?: ""
key to value
val paramMap = parseAirsyncParams(urlString)
pcName = paramMap["name"]?.let { android.net.Uri.decode(it) }
isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false
symmetricKey = paramMap["key"]?.let {
android.net.Uri.decode(it)
}
relayUrl = paramMap["relay"]?.let { android.net.Uri.decode(it) }
airBridgePairingId =
paramMap["pairingId"]?.let { android.net.Uri.decode(it) }
airBridgeSecret =
paramMap["secret"]?.let { android.net.Uri.decode(it) }
}

if (!relayUrl.isNullOrBlank() &&
!airBridgePairingId.isNullOrBlank() &&
!airBridgeSecret.isNullOrBlank()
) {
lifecycleScope.launch {
try {
val ds = DataStoreManager.getInstance(this@MainActivity)
ds.setAirBridgeRelayUrl(relayUrl!!)
ds.setAirBridgePairingId(airBridgePairingId!!)
ds.setAirBridgeSecret(airBridgeSecret!!)
ds.setAirBridgeEnabled(true)
Comment on lines +377 to +383
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DataStoreManager.getInstance(this@MainActivity) stores the passed Context in a static singleton; passing an Activity context risks leaking the Activity. Use applicationContext when calling getInstance() here (and anywhere else a long-lived singleton is used).

Copilot uses AI. Check for mistakes.

// Provide symmetric key to AirBridgeClient immediately so it doesn't
// refuse connection waiting for a completed LAN handshake first
if (!symmetricKey.isNullOrEmpty()) {
AirBridgeClient.updateSymmetricKey(symmetricKey)
}

AirBridgeClient.disconnect()
AirBridgeClient.connect(this@MainActivity)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to apply AirBridge QR config: ${e.message}", e)
}
pcName = paramMap["name"]?.let { URLDecoder.decode(it, "UTF-8") }
isPlus = paramMap["plus"]?.toBooleanStrictOrNull() ?: false
symmetricKey = paramMap["key"]
}
}

Expand Down Expand Up @@ -580,6 +610,22 @@ class MainActivity : ComponentActivity() {
return AdbDiscoveryHolder.getDiscoveredServices()
}

private fun parseAirsyncParams(urlString: String): Map<String, String> {
val queryPart = urlString.substringAfter('?', "")
if (queryPart.isEmpty()) return emptyMap()

return queryPart.split('?')
.mapNotNull { raw ->
if (raw.isBlank()) return@mapNotNull null
val parts = raw.split('=', limit = 2)
val key = parts.getOrNull(0)?.trim().orEmpty()
if (key.isEmpty()) return@mapNotNull null
val value = parts.getOrNull(1).orEmpty()
key to value
}
.toMap()
Comment on lines +613 to +626
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseAirsyncParams() splits the query string on ?, which won’t parse standard query params separated by &. This can break deep links/QR links that follow the documented format. Prefer Uri query parameter APIs or split on & (and optionally support legacy ?-delimited payloads).

Copilot uses AI. Check for mistakes.
}

/**
* Handles intents related to the Notes Role feature.
* Extracts stylus mode hint and lock screen status from the intent.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ class DataStoreManager(private val context: Context) {
// Widget preferences
private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency")

// AirBridge relay preferences
private val AIRBRIDGE_ENABLED = booleanPreferencesKey("airbridge_enabled")
private val AIRBRIDGE_RELAY_URL = stringPreferencesKey("airbridge_relay_url")
private val AIRBRIDGE_PAIRING_ID = stringPreferencesKey("airbridge_pairing_id")
private val AIRBRIDGE_SECRET = stringPreferencesKey("airbridge_secret")

private const val NETWORK_DEVICES_PREFIX = "network_device_"
private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_"

Expand Down Expand Up @@ -604,6 +610,40 @@ class DataStoreManager(private val context: Context) {
}
}

// --- AirBridge Relay ---

suspend fun setAirBridgeEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[AIRBRIDGE_ENABLED] = enabled }
}

fun getAirBridgeEnabled(): Flow<Boolean> {
return context.dataStore.data.map { it[AIRBRIDGE_ENABLED] ?: false }
}

suspend fun setAirBridgeRelayUrl(url: String) {
context.dataStore.edit { prefs -> prefs[AIRBRIDGE_RELAY_URL] = url }
}

fun getAirBridgeRelayUrl(): Flow<String> {
return context.dataStore.data.map { it[AIRBRIDGE_RELAY_URL] ?: "" }
}

suspend fun setAirBridgePairingId(id: String) {
context.dataStore.edit { prefs -> prefs[AIRBRIDGE_PAIRING_ID] = id }
}

fun getAirBridgePairingId(): Flow<String> {
return context.dataStore.data.map { it[AIRBRIDGE_PAIRING_ID] ?: "" }
}

suspend fun setAirBridgeSecret(secret: String) {
context.dataStore.edit { prefs -> prefs[AIRBRIDGE_SECRET] = secret }
}

fun getAirBridgeSecret(): Flow<String> {
return context.dataStore.data.map { it[AIRBRIDGE_SECRET] ?: "" }
}

// Network-aware device connections
suspend fun saveNetworkDeviceConnection(
deviceName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ data class UiState(
val defaultTab: String = "dynamic",
val isEssentialsConnectionEnabled: Boolean = false,
val activeIp: String? = null,
val isRelayConnection: Boolean = false,
val connectingDeviceId: String? = null,
val isDeviceDiscoveryEnabled: Boolean = true,
val shouldShowRatingPrompt: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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.AirBridgeCard
import com.sameerasw.airsync.presentation.ui.components.cards.SendNowPlayingCard
import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard
import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
Expand Down Expand Up @@ -363,6 +364,8 @@ fun SettingsView(
)

ExpandNetworkingCard(context)

AirBridgeCard(context)
}
}

Expand Down
Loading
Loading