feat: AirBridge relay - connect Mac and Android when not on the same network (Android Integration)#102
feat: AirBridge relay - connect Mac and Android when not on the same network (Android Integration)#102tornado-bunk wants to merge 27 commits intosameerasw:mainfrom
Conversation
- 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.
There was a problem hiding this comment.
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
AirBridgeClientWebSocket 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 |
There was a problem hiding this comment.
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.
| import androidx.compose.foundation.layout.Spacer |
| 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() |
There was a problem hiding this comment.
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).
| 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() |
There was a problem hiding this comment.
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).
| lifecycleScope.launch { | ||
| try { | ||
| val ds = DataStoreManager.getInstance(this@MainActivity) | ||
| ds.setAirBridgeRelayUrl(relayUrl!!) | ||
| ds.setAirBridgePairingId(airBridgePairingId!!) | ||
| ds.setAirBridgeSecret(airBridgeSecret!!) | ||
| ds.setAirBridgeEnabled(true) |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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.
| 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 |
| try { | ||
| val ips = data?.optString("ips", "") ?: "" | ||
| val port = data?.optInt("port", -1) ?: -1 | ||
| val adapter = data?.optString("adapter", "auto") ?: "auto" |
There was a problem hiding this comment.
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.
| val adapter = data?.optString("adapter", "auto") ?: "auto" |
| * Allows LAN flow to explicitly refresh the relay key, so transport switching is seamless. | ||
| */ | ||
| fun updateSymmetricKey(base64Key: String?) { | ||
| symmetricKey = base64Key?.let { CryptoUtil.decodeKey(it) } |
There was a problem hiding this comment.
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).
| 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) | |
| } |
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| val sent = if (transport == "relay") { | ||
| if (AirBridgeClient.isRelayConnectedOrConnecting()) { | ||
| AirBridgeClient.sendMessage(payload) | ||
| } else { | ||
| sendMessage(payload) | ||
| } | ||
| } else { | ||
| sendMessage(payload) | ||
| } |
There was a problem hiding this comment.
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.
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 routingAirBridgeCard.kt— Compose UI card for configuring AirBridge (relay URL, pairing ID, secret), showing live connection state and peer health statusModified files:
WebSocketUtil.ktsendMessage()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 clientWebSocketMessageHandler.ktmacWake(triggers LAN reconnect retry),peerTransport,transportOffer/Answer/Check/CheckAck/Nominate; lightweight pong for keepalive; candidate extraction with IP sanitizationAirSyncViewModel.ktAirSyncMainScreen.ktairsync://URI with relay configuration parameters; transport badge in UI; AirBridge card integrationConnectionStatusCard.ktLastConnectedDeviceCard.ktDataStoreManager.ktairbridge_enabled,airbridge_relay_url,airbridge_pairing_id,airbridge_secretUiState.ktisRelayConnectionfieldAirSyncService.ktAirSyncApp.ktMainActivity.ktairsync://deep-link intent handling for relay configurationWakeupService.ktreuseAddress = trueto prevent bind failures after rapid service restartsSettingsView.ktKnown Limitations
How to Test
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