Skip to content

feat: AirBridge relay - connect Mac and Android when not on the same network (Android Integration)#102

Open
tornado-bunk wants to merge 27 commits intosameerasw:mainfrom
tornado-bunk:feat/airbridge-server
Open

feat: AirBridge relay - connect Mac and Android when not on the same network (Android Integration)#102
tornado-bunk wants to merge 27 commits intosameerasw:mainfrom
tornado-bunk:feat/airbridge-server

Conversation

@tornado-bunk
Copy link
Copy Markdown

Summary

This PR introduces AirBridge, a self-hosted relay server integration that enables AirSync to work even when Mac and Android are not on the same local network (you can find the server here: AirBridge). The relay acts as an encrypted WebSocket bridge: when a direct LAN connection isn't available, messages are tunneled through the relay server.

Motivation

AirSync is already amazing for local use, but I wanted to extend its versatility to support remote workflows (hotspots, office networks, etc.) without relying on third-party cloud providers.


What's Changed

New files:

  • AirBridgeClient.kt — Singleton WebSocket client for the relay server. Handles HMAC-SHA256 challenge-response authentication, exponential backoff reconnection, peer status monitoring, symmetric key management, and message routing
  • AirBridgeCard.kt — Compose UI card for configuring AirBridge (relay URL, pairing ID, secret), showing live connection state and peer health status

Modified files:

File Changes
WebSocketUtil.kt Relay fallback for sendMessage() when LAN is down, LAN-first relay probing with adaptive intervals (fast → medium → slow), transport offer/answer/nomination exchange, peer transport status notifications, symmetric key sync with relay client
WebSocketMessageHandler.kt New message types: macWake (triggers LAN reconnect retry), peerTransport, transportOffer/Answer/Check/CheckAck/Nominate; lightweight pong for keepalive; candidate extraction with IP sanitization
AirSyncViewModel.kt Unified connection state that merges LAN and relay status; relay-aware network change handling (keeps relay alive when Wi-Fi drops instead of killing everything); LAN-first probing bootstrap on app restart
AirSyncMainScreen.kt Deep-link support for airsync:// URI with relay configuration parameters; transport badge in UI; AirBridge card integration
ConnectionStatusCard.kt Relay connection indicator, peer health badge
LastConnectedDeviceCard.kt Transport-aware status display
DataStoreManager.kt AirBridge preferences: airbridge_enabled, airbridge_relay_url, airbridge_pairing_id, airbridge_secret
UiState.kt New isRelayConnection field
AirSyncService.kt Relay-aware foreground/background transitions: sends transport offers and starts LAN probing when app comes to foreground; attempts LAN reconnect when Wi-Fi becomes available while relay is active
AirSyncApp.kt AirBridge auto-connect on app start when enabled
MainActivity.kt airsync:// deep-link intent handling for relay configuration
WakeupService.kt HTTP server socket now uses reuseAddress = true to prevent bind failures after rapid service restarts
SettingsView.kt AirBridge settings entry

Known Limitations

  • LAN ↔ relay switching after Mac sleep/wake or network changes may not always be seamless — in rare cases the UI might briefly show the wrong transport or require a manual reconnect
  • Quick Share and ADB are disabled when connected via relay (they require a direct LAN connection)
  • Requires a self-hosted AirBridge relay server

How to Test

  1. Deploy an AirBridge relay server
  2. On Mac: enable AirBridge in Settings or during onboarding, enter the relay URL, and use the generated pairing ID + secret
  3. On Android: enter the same relay URL, pairing ID, and secret (via QR or manually)
  4. Disconnect from the same Wi-Fi → verify connection falls back to relay
  5. Reconnect to the same Wi-Fi → verify connection upgrades back to LAN

Note

This feature is marked as Beta. The relay itself is stable and functional — the "beta" designation reflects that the automatic switching between LAN and relay transport can occasionally be imperfect (e.g. after a Mac sleep/wake cycle or a network change). This will be refined in future updates.
This implementation was developed using an AI-assisted workflow. While AI helped accelerate the cross-platform integration and the Go relay logic, the entire architecture was manually designed and every part of the code has been personally reviewed.


Screenshots

Home Settings
home settings

- Introduced `AirBridgeClient`, a WebSocket-based relay system allowing remote connections when devices are not on the same network.
- Implemented a challenge-response authentication mechanism for LAN connections using HMAC-SHA256.
- Added `AirBridgeCard` to settings for configuring relay server URL, pairing ID, and secret.
- Enhanced QR code parsing to support AirBridge credentials and modern URL parameter separators (`&`).
- Updated `WebSocketUtil` to support seamless failover between LAN and relay transports.
- Implemented a thread-safe nonce replay guard in `CryptoUtil` to prevent replay attacks on encrypted messages.
- Added a "Connect with Relay" option to the last connected device card.
- Improved `FileReceiver` to handle duplicate chunks and prevent progress notification spam.
- Unified connection state monitoring in `AirSyncViewModel` to reflect both LAN and relay status.
- Added `requestLanReconnectFromRelay` to `WebSocketUtil` to attempt re-establishing direct LAN connections when WiFi becomes available while a relay is active.
- Updated `AirSyncService` to trigger LAN reconnection attempts upon WiFi availability.
- Enhanced `ConnectionStatusCard` with a transport indicator to distinguish between "Local" and "Relay" (AirBridge) connections.
- Updated `UiState` and `AirSyncViewModel` to track and display relay connection status.
- Improved `WakeupService` HTTP server stability by enabling `SO_REUSEADDR` on the server socket.
- Simplify `CryptoUtil` by removing `NonceReplayGuard`, HMAC-SHA256, and hex conversion utilities
- Update `AirBridgeCard` relay URL placeholder
- Remove connection transport indicator (Local/Relay) from `ConnectionStatusCard`
- Implement periodic LAN reconnection attempts while the relay is active to prefer local paths when available.
- Update `AirSyncViewModel` to prevent stopping the service or relay when Wi-Fi is lost if a relay connection is active.
- Introduce a connection generation counter in `AirBridgeClient` to prevent race conditions and stale WebSocket callbacks.
- Add safeguards against duplicate relay connection attempts.
- Ensure the service triggers immediate LAN retries when a relay is active during scanning.
- Improved error handling for cancelled discovery coroutines.
- Implemented `peerReallyActive` state tracking in `AirBridgeClient` using periodic `query_status` polling.
- Added a "Peer offline" indicator to the `AirBridgeCard` UI when the relay is active but the peer is disconnected.
- Added a `macWake` message handler to trigger LAN reconnection requests via the relay.
- Improved cleanup of connection states and polling jobs during disconnection.
- Added `notifyPeerTransportChanged` to `WebSocketUtil` to inform the peer (desktop) about active transport changes (Wi-Fi/LAN vs. Relay).
- Implemented transport status advertisements during LAN handshake completion and relay connection establishment.
- Added `peerTransport` message type handling in `WebSocketMessageHandler`.
- Reset advertised transport state upon manual disconnection.
…scovery

- Added transport change logging in WebSocketUtil
- Dynamic transport notification (WiFi vs Relay) based on connectivity when relay connects
- Set ACTION_START_DISCOVERY when starting QuickShareService to ensure proper foreground initialization
- Updated `handleMacWake` to accept additional data for improved LAN reconnection attempts.
- Implemented a retry loop for LAN reconnects to avoid spamming and overlapping jobs.
- Added logging for connection attempts and fallback strategies to ensure robust handling of macWake events.
- Introduced a `startLanFirstRelayProbe` loop in `WebSocketUtil` that periodically attempts to recover direct LAN connections while a relay is active.
- Implemented an adaptive polling interval (15s/30s/60s) based on the elapsed time since the probe started to balance responsiveness and power consumption.
- Integrated the LAN probe into `AirBridgeClient` lifecycle events, triggering it when a relay starts and stopping it on manual disconnection or socket failure.
- Updated `AirSyncService` to initiate LAN probing when the app enters the foreground or when Wi-Fi becomes available.
- Refactored `macWake` handling in `WebSocketMessageHandler` to use the new probing logic for more reliable LAN recovery after a wake event.
- Enhanced `requestLanReconnectFromRelay` with source tracking and a short-term debounce to prevent overlapping reconnection attempts.
- Implemented immediate LAN-first probing in `AirSyncViewModel` when a relay connection is active but the local WebSocket is disconnected.
- Added relay-status checks in `requestAutoReconnect` to trigger LAN discovery during cold starts or process restarts.
- Configured bootstrap probes to run immediately with a backoff reset to prioritize local path transitions over existing relay connections.
…AN reconnections

- Added multiple transport offer triggers in `AirSyncService` for various network states to improve LAN reconnection attempts.
- Implemented candidate extraction logic in `WebSocketMessageHandler` to validate and process incoming transport offers.
- Introduced new methods for handling transport offers and answers, ensuring robust negotiation during LAN-first probing.
- Enhanced `WebSocketUtil` with additional checks and state management for transport generation and LAN probing cooldowns.
- Updated relay connection text from "via AirBridge relay" to "AirBridge" for a more concise UI.
- Replaced the textual "Peer online/offline" status badge with a compact colored status dot.
- Replaced static secret hashing with an HMAC-SHA256 challenge-response flow in `AirBridgeClient`.
- Added `CHALLENGE_RECEIVED` state to track the authentication phase after receiving a server nonce.
- Implemented `computeHmac` to generate an HMAC signature and `kInit` session bootstrap key.
- Updated `onOpen` to wait for a server challenge instead of immediately sending registration.
- Updated `AirBridgeCard` UI to display "Authenticating..." during the challenge-response phase.
- Removed unused `isPeerReallyActive` helper and cleaned up stale variable references.
- Updated the query string splitting logic in `MainActivity` to use '?' as the sole delimiter.
- Removed the regex-based split that provided backward compatibility for '&' separators.
- Simplify URI query parameter parsing logic by using a single delimiter.
- Re-enable the FAB expansion hint on connection status changes with a 5-second auto-collapse.
- Remove `AnimatedVisibility` from the `AirSyncFloatingToolbar` to ensure it remains visible regardless of scroll state.
- Clean up unused `SuppressLint` and optimize scroll tracking state.
…hronization

- Removed redundant and verbose debug/info logs across `AirBridgeClient`, `WebSocketUtil`, and `WebSocketMessageHandler` to streamline production output.
- Simplified error messages by removing exception detail string concatenation in several `Log.e` calls.
- Renamed unused parameters (e.g., `reason` to `_reason`) to satisfy linting or clarify intent.
- Improved log safety by removing specific PII/metadata like IP addresses and port numbers from certain debug statements.
- Retained critical security warnings and high-level state transition failures.
- Streamlined transport synchronization logic by dropping stale or inactive generation updates with more concise warnings.
- Updated the title in `AirBridgeCard` to "AirBridge Relay (Beta)" to reflect the feature's current status.
- Redded `java.net.URLDecoder` import to `MainActivity.kt`.
- Updated the `onMessage` callback in `WebSocketUtil` to log an error when a message fails to decrypt using the current symmetric key.
Copilot AI review requested due to automatic review settings March 30, 2026 18:32
@tornado-bunk tornado-bunk changed the title AirBridge relay - connect Mac and Android when not on the same network (Android Integration) feat: AirBridge relay - connect Mac and Android when not on the same network (Android Integration) Mar 30, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Integrates AirBridge (self-hosted relay) into the Android app to enable AirSync connectivity and message routing when Mac and Android are not on the same LAN, with LAN-first upgrade probing and new UI/settings for relay configuration.

Changes:

  • Added AirBridgeClient WebSocket relay client with challenge/response auth, encryption, reconnection, and peer-health polling.
  • Implemented LAN↔relay transport orchestration (fallback, probing, and offer/answer/check/nominate messaging).
  • Added UI + persistence for relay credentials and connection indicators (settings card, badges, deep-link/QR support).

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt Adds relay fallback routing, LAN-first probing loop, and transport negotiation helpers.
app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt Adds new transport-related message types, candidate extraction/sanitization, keepalive pong, and macWake handling.
app/src/main/java/com/sameerasw/airsync/utils/AirBridgeClient.kt New relay client (auth, encryption, reconnect backoff, status polling, message forwarding).
app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt Enables reuseAddress to reduce bind failures on rapid restarts.
app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt Triggers transport offers and LAN-first probing when app/network state changes while relay is active.
app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt Unifies LAN+relay connection state and relay-aware network handling.
app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt Adds deep-link/QR ingestion for relay config and a “connect with relay” flow.
app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt Adds AirBridge settings card into Settings screen.
app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt Adds “Connect with Relay” action alongside Quick Connect.
app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt Displays relay badge + peer health indicator when connected via relay.
app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/AirBridgeCard.kt New Compose card for relay configuration + live state display.
app/src/main/java/com/sameerasw/airsync/MainActivity.kt Handles deep-link relay params and applies them to DataStore / relay client.
app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt Adds isRelayConnection to drive relay-specific UI.
app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt Persists relay enable flag + relay URL/pairing ID/secret.
app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt Wires relay messages into existing message handler and auto-connects relay at startup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.Spacer
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.

Duplicate import of Spacer (same symbol imported twice) can fail linting/format checks and is unnecessary. Remove the redundant import so the file compiles cleanly and stays consistent with the rest of the codebase.

Suggested change
import androidx.compose.foundation.layout.Spacer

Copilot uses AI. Check for mistakes.
Comment on lines 456 to +466
val queryPart = uri.toString().substringAfter('?', "")
if (queryPart.isNotEmpty()) {
val params = queryPart.split('?')
val paramMap = params.associate { param ->
val parts = param.split('=', limit = 2)
val key = parts.getOrNull(0) ?: ""
val value = parts.getOrNull(1) ?: ""
key to value
}
pcName = paramMap["name"]?.let { URLDecoder.decode(it, "UTF-8") }
val paramMap = 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
key to (parts.getOrNull(1).orEmpty())
}
.toMap()
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.

QR query parsing splits on ?, but the comment/expected format uses standard & separators (e.g., ...?name=...&plus=...). This will fail to parse real URIs that use &. Consider parsing via Uri.getQueryParameter(...) (or split on both & and ? for backwards compatibility).

Copilot uses AI. Check for mistakes.
Comment on lines +613 to +626
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()
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.
Comment on lines +377 to +383
lifecycleScope.launch {
try {
val ds = DataStoreManager.getInstance(this@MainActivity)
ds.setAirBridgeRelayUrl(relayUrl!!)
ds.setAirBridgePairingId(airBridgePairingId!!)
ds.setAirBridgeSecret(airBridgeSecret!!)
ds.setAirBridgeEnabled(true)
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.
Comment on lines +490 to +495
if (ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("100.")) return true
if (!ip.startsWith("172.")) return false
val parts = ip.split(".")
if (parts.size < 2) return false
val second = parts[1].toIntOrNull() ?: return false
return second in 16..31
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.

isPrivateOrAllowedLocalIp() treats any 100.* address as allowed. 100.0.0.0/8 is not private (only 100.64.0.0/10 is CGNAT), so this filter is overly permissive and inconsistent with WebSocketUtil.isPrivateLanIp(). Tighten this to RFC1918 (and, if needed, only 100.64/10) to avoid accepting unintended candidates.

Suggested change
if (ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("100.")) return true
if (!ip.startsWith("172.")) return false
val parts = ip.split(".")
if (parts.size < 2) return false
val second = parts[1].toIntOrNull() ?: return false
return second in 16..31
val parts = ip.split(".")
if (parts.size != 4) return false
val first = parts[0].toIntOrNull() ?: return false
val second = parts[1].toIntOrNull() ?: return false
// 10.0.0.0/8
if (first == 10) return true
// 192.168.0.0/16
if (first == 192 && second == 168) return true
// 172.16.0.0/12
if (first == 172 && second in 16..31) return true
// 100.64.0.0/10 (CGNAT range)
if (first == 100 && second in 64..127) return true
return false

Copilot uses AI. Check for mistakes.
try {
val ips = data?.optString("ips", "") ?: ""
val port = data?.optInt("port", -1) ?: -1
val adapter = data?.optString("adapter", "auto") ?: "auto"
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.

adapter is read from the macWake payload but never used. If it’s not needed, remove it to avoid dead code; if it is needed, apply it (e.g., to choose a candidate set) so the field has an effect.

Suggested change
val adapter = data?.optString("adapter", "auto") ?: "auto"

Copilot uses AI. Check for mistakes.
* Allows LAN flow to explicitly refresh the relay key, so transport switching is seamless.
*/
fun updateSymmetricKey(base64Key: String?) {
symmetricKey = base64Key?.let { CryptoUtil.decodeKey(it) }
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.

updateSymmetricKey() calls CryptoUtil.decodeKey() which can throw on invalid base64; this can crash the app when applying QR-provided relay credentials. Mirror the defensive decode used in WebSocketUtil.connect() (catch exceptions and keep the previous key / set null + log).

Suggested change
symmetricKey = base64Key?.let { CryptoUtil.decodeKey(it) }
if (base64Key == null) {
// Explicitly clear the symmetric key when no key is provided.
symmetricKey = null
return
}
try {
// Decode and update the symmetric key; this may throw on invalid Base64.
symmetricKey = CryptoUtil.decodeKey(base64Key)
} catch (e: Exception) {
// Mirror defensive handling from WebSocketUtil.connect(): keep previous key and log.
Log.e(TAG, "Failed to decode symmetric key from provided credentials; keeping previous key", e)
}

Copilot uses AI. Check for mistakes.
Comment on lines +546 to +555
if (processedMessage == null) {
// Decryption failed or no key available.
// Check if the raw message looks like valid JSON (plaintext fallback).
if (text.trim().startsWith("{")) {
Log.w(TAG, "Decryption failed (or no key), falling back to plaintext processing")
processedMessage = text
} else {
Log.e(TAG, "SECURITY: Decryption failed and message is not JSON. Dropping.")
return
}
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.

Incoming relay payloads fall back to plaintext processing when decryption fails (any message starting with {). This defeats the relay’s encryption guarantee and can allow message injection/downgrade if the relay/network is malicious or misconfigured. Prefer dropping the message (or transitioning to FAILED) on decryption failure; control messages are already handled before this block.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +116
val sent = if (transport == "relay") {
if (AirBridgeClient.isRelayConnectedOrConnecting()) {
AirBridgeClient.sendMessage(payload)
} else {
sendMessage(payload)
}
} else {
sendMessage(payload)
}
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.

When transport == "relay", this uses isRelayConnectedOrConnecting() and then calls AirBridgeClient.sendMessage(). sendMessage() only succeeds in RELAY_ACTIVE/WAITING_FOR_PEER, so during CONNECTING/REGISTERING this will return false and you won’t fall back to LAN send. Consider switching the guard to isRelayActive() (or retry/fallback to sendMessage(payload) when relay send fails) to avoid silently dropping the transport notification.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants