Skip to content

Device Linking Production Testing

HlexNC edited this page Mar 14, 2026 · 9 revisions

Device Linking — Production Testing & Bug Fixes

Status: v0.4.0-test — 4 additional bugs found and fixed ✅ (see bottom) Previous: v0.1.2-test — 4 bugs found and fixed ✅ Date: 2026-02-28 Scope: Desktop (Windows) ↔ Mobile (Android) over same WiFi / desktop hotspot


Overview

During v0.1.2-test, device linking worked in dev mode but failed completely when testing the installed .exe + .apk over a real local network. This document records what was tested, the four root-cause bugs found, and the fixes applied.


Test Environment

Component Version
Desktop installer desktop-temp_0.1.0_x64-setup.exe
Android APK app-release.apk (release build, JS bundled via assembleRelease)
Desktop OS Windows 11 Home (arm64)
Mobile OS Android
Network Same WiFi router + Windows Mobile Hotspot

Bugs Found

Bug 1 — Wrong IP in QR Code (breaks hotspot scenario)

Root cause: local_ip() from the local-ip-address crate returns the primary outbound interface IP only (e.g., 192.168.1.133). When the desktop creates a Windows Mobile Hotspot, Windows creates a virtual adapter at 192.168.137.1. The phone connects to the hotspot subnet (192.168.137.x) and cannot route to 192.168.1.133 — a completely different subnet.

File: apps/desktop/src-tauri/src/device_linking.rsgenerate_qr_code()

Fix: Use if_addrs::get_if_addrs() to enumerate all non-loopback IPv4 addresses and embed them comma-separated in the QR payload:

zelara://pair?ips=192.168.137.1,192.168.1.133&port=8765&token=xyz

Bug 2 — Server Bound to Wrong Interface

Root cause: The WebSocket server bound to {local_ip}:8765 — a specific interface. Even if the mobile knew the hotspot IP, the server wasn't listening on that interface.

File: apps/desktop/src-tauri/src/device_linking.rsstart_pairing_server(), line:

let addr = format!("{}:8765", ip);  // was: only one interface

Fix: Bind to 0.0.0.0:8765 so the server accepts connections on all interfaces simultaneously:

let addr = "0.0.0.0:8765".to_string();

Bug 3 — Windows Firewall Has No Inbound Rule for Port 8765

Root cause: In dev mode, the first run of the Rust binary triggers Windows' "Allow access?" popup, which the developer approved. The production .exe installs to a different path — it never gets that popup and no firewall rule is added. Windows Firewall silently drops all inbound connections on port 8765.

Fix (in-app): On first server start, check if the rule exists; if not, spawn an elevated PowerShell process (one-time UAC prompt) to add the rule:

let rule_exists = std::process::Command::new("netsh")
    .args(["advfirewall", "firewall", "show", "rule", "name=Zelara Device Linking"])
    .output()
    .map(|o| o.status.success())
    .unwrap_or(false);

if !rule_exists {
    let _ = std::process::Command::new("powershell")
        .args(["-WindowStyle", "Hidden", "-Command",
               "Start-Process netsh -ArgumentList 'advfirewall firewall add rule \
                name=\"Zelara Device Linking\" dir=in action=allow protocol=TCP \
                localport=8765' -Verb RunAs -Wait"])
        .output();
}

The UAC prompt appears once per machine. After the rule is created it is never shown again.


Bug 4 — Android Cleartext Traffic Blocked in Release Builds

Root cause: Android 9+ blocks all cleartext (unencrypted) traffic by default. WebSocket ws:// is cleartext. The debug manifest (src/debug/AndroidManifest.xml) already sets android:usesCleartextTraffic="true", but this only applies to debug builds. Release builds use the main manifest (src/main/AndroidManifest.xml) exclusively, which had no cleartext setting → Android blocks all ws:// connections before they reach the network.

Symptom: ws.onerror fires immediately for all IPs (not after the 3-second timeout), and the hardcoded "Connection refused" message masks the real Android error (Cleartext HTTP traffic to X not permitted). This makes the failure indistinguishable from a real TCP refusal.

Files: apps/mobile/android/app/src/main/AndroidManifest.xml — missing networkSecurityConfig

Fix: Create apps/mobile/android/app/src/main/res/xml/network_security_config.xml with cleartextTrafficPermitted="true" for all hosts (local IPs are dynamic so cannot be enumerated statically), and reference it from the main manifest:

<!-- network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors><certificates src="system"/></trust-anchors>
    </base-config>
</network-security-config>
<!-- AndroidManifest.xml <application> tag -->
android:networkSecurityConfig="@xml/network_security_config"

Changes Made

Desktop (apps/desktop/)

File Change
src-tauri/Cargo.toml Added if-addrs = "0.13" dependency
src-tauri/src/device_linking.rs get_all_local_ips() using if_addrs; QR uses ips= param; server binds 0.0.0.0:8765; UAC firewall rule on first run
src/components/DevicePairing.tsx Displays all available IPs in the UI

Mobile (apps/mobile/)

File Change
src/services/DeviceLinkingService.ts connect() accepts string | string[]; tries each IP with 3s timeout, stops on first success; onerror logs actual event for debugging
src/screens/DevicePairingScreen.tsx Parses ips= param (comma-separated); falls back to legacy ip= param
android/app/src/main/res/xml/network_security_config.xml New — allow cleartext ws:// in release builds (Bug 4 fix)
android/app/src/main/AndroidManifest.xml Reference network_security_config to fix cleartext blocking in release builds (Bug 4 fix)

Build Instructions

Desktop (dev)

cd apps/desktop
npm run tauri:dev

Desktop (production)

cd apps/desktop
npm run tauri:build
# Installer: src-tauri/target/release/bundle/nsis/desktop-temp_0.1.0_x64-setup.exe

Mobile (release — JS bundled, no Metro needed)

cd apps/mobile/android
./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk

Mobile (debug — needs Metro or/and adb reverse)

# 1. Start Metro bundler (keep running in a separate terminal)
cd apps/mobile
npm start

# 2. Build and install the APK
cd apps/mobile/android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

# 3. Forward Metro port to device
adb reverse tcp:8081 tcp:8081

# 4. The app might need a proper restart
adb shell am force-stop ai.zelara.mobile
adb shell am start -n ai.zelara.mobile/.MainActivity

Test Procedure

Pre-conditions

  • Both devices on same network (same WiFi router or phone connected to desktop's Windows Mobile Hotspot)
  • Desktop app running, WebSocket server started

Steps

  1. Verify firewall rule (first run only):

    netsh advfirewall firewall show rule name="Zelara Device Linking"

    Expected: rule listed with Action: Allow, LocalPort: 8765

  2. Verify server is listening on all interfaces:

    netstat -an | findstr :8765
    # netstat -an | Select-String ":8765"

    Expected: TCP 0.0.0.0:8765 0.0.0.0:0 LISTENING

  3. Generate QR code on desktop → confirm multiple IPs shown in UI (e.g., 192.168.137.1, 192.168.1.133)

  4. Scan QR from mobile → mobile auto-tries each IP → connects to the reachable one

  5. Confirm connection:

    Location Expected output
    Desktop terminal New connection from: 192.168.x.x:PORT
    Desktop UI Device appears after "Refresh Devices"
    Mobile "Device Linked!" alert

Pass Criteria

  • Same WiFi (router): connects without manual firewall steps on second+ run
  • Desktop hotspot: phone connects via 192.168.137.1 (hotspot adapter IP)
  • First run: UAC prompt appears, user approves, rule added, linking works
  • Subsequent runs: no UAC prompt

Known Limitations

Limitation Status
Mobile data (cellular) linking Not supported — private IP unreachable over carrier NAT. Requires relay server or NAT traversal (future)
Token expires on desktop restart Re-scan QR after restarting desktop. Persistent pairing planned for v2
macOS/Linux firewall netsh is Windows-only. Other platforms may need ufw/pfctl equivalents (not yet implemented)

Related Documentation


v0.2.0-test — Desktop UI Auto-Update & Camera Inversion Test

Status: Fixed ✅ Date: 2026-02-28 Scope: Desktop UI real-time updates + Mobile camera-based image inversion test


Changes Made

Fix 1 — Desktop "Linked Devices" Not Updating

Root cause: DevicePairing.tsx only called get_linked_devices when the user clicked "Refresh Devices". The Rust backend registered devices correctly, but the React frontend had no way to know about new connections in real time.

Fix: Used Tauri v2's event system to push a notification from the backend to the UI immediately when a device registers.

File Change
apps/desktop/src-tauri/src/device_linking.rs Added app_handle: tauri::AppHandle param to start_pairing_server; passed handle through the call chain; emits device-linked event after device registers
apps/desktop/src/components/DevicePairing.tsx Subscribes to device-linked event on mount; appends new device to list without requiring refresh
apps/desktop/src/components/TestingPanel.tsx Also subscribes to device-linked to keep device count accurate

Result: Desktop UI shows the newly linked device instantly when mobile scans the QR code — no button press required.


Fix 2 — Camera-Based Image Inversion Test + Result on Both Devices

Root cause: TestingScreen.tsx used a hardcoded 1×1 px red pixel instead of a real camera photo. The desktop TestingPanel.tsx had no mechanism to display test results — it only showed a placeholder log with a "Simulate Test" button.

Fix:

  1. Mobile now uses react-native-vision-camera to take a real camera photo, reads it as base64 with react-native-fs, and sends it to Desktop.
  2. Desktop Rust processes the inversion and emits an image-inversion-result Tauri event with both the original and inverted base64 images.
  3. Desktop React (TestingPanel.tsx) listens for the event and displays both images side-by-side in real time.
File Change
apps/desktop/src-tauri/src/device_linking.rs After successful inversion, emits image-inversion-result event with { original, inverted, device, timestamp }
apps/desktop/src/components/TestingPanel.tsx Subscribes to image-inversion-result; renders original + inverted images when received; shows real device count via get_linked_devices on mount
apps/desktop/src/components/TestingPanel.css Added .test-result-display, .image-comparison, .image-comparison-item styles
apps/mobile/src/screens/TestingScreen.tsx Full camera capture flow (Take Photo → Retake → preview → Run Test); sends real JPEG; modal shows original JPEG + inverted PNG

End-to-End Test Procedure (v0.2.0-test)

Pre-conditions

  • Desktop app running (dev or production build)
  • Mobile APK installed (release or debug with Metro)
  • Both devices on the same WiFi or desktop hotspot

Steps

  1. Start Desktop, click Generate QR Code

    • Expected: QR code appears, multiple IPs shown below it
  2. On Mobile, go to Device Pairing → scan QR code

    • Expected (Desktop UI): "Linked Devices (1)" appears immediately in the Device Pairing section, without clicking "Refresh Devices"
    • Expected (Desktop Testing panel): Device count changes from 0 to 1 and a log entry appears
  3. On Mobile, navigate to Testing tab

    • Expected: "Connected to Desktop" shown in green
  4. Tap Take Photo, point camera at any object, press capture button

    • Expected: photo preview appears in the Testing screen
  5. Tap Run Image Inversion Test

    • Expected (Mobile): spinner while processing, then modal appears showing original photo on the left and the color-inverted version on the right
    • Expected (Desktop Testing panel): immediately shows "Last Inversion Test" section with the same two images, plus a log entry with the device address and timestamp

Pass Criteria

  • Desktop device list updates without refresh after QR scan
  • Desktop Testing panel device count increments when mobile connects
  • Mobile camera opens and captures a real photo
  • Mobile inversion test sends real photo (not hardcoded pixel)
  • Desktop displays both images within ~1 second of mobile sending
  • Mobile modal shows original and inverted images correctly

v0.3.0-test — Proper Handshake on Device Linking

Status: Implemented ✅ Date: 2026-03-01 Scope: Mobile → Desktop pairing handshake + Desktop toast notification


Problem

In v0.2.0-test, the Desktop only registered a mobile device when the first WebSocket message with a token arrived. The first such message was a counter_update from the Testing screen — meaning the Desktop's "Linked Devices" list stayed empty until the user navigated to the Testing tab.

The mobile showed "Device Linked!" immediately after ws.onopen, before any message was sent to the Desktop. This was not a true handshake confirmation.


Changes Made

Mobile (apps/mobile/)

File Change
src/services/DeviceLinkingService.ts Added sendHandshake() method — sends { taskType: 'handshake', payload: { token } } and returns a promise that resolves only on Desktop confirmation (5s timeout)
src/screens/DevicePairingScreen.tsx Added await DeviceLinkingService.sendHandshake() immediately after connect() — "Device Linked!" alert now only appears after Desktop confirms

Desktop (apps/desktop/)

File Change
src-tauri/src/device_linking.rs Added "handshake" arm to the match request.task_type.as_str() block — returns { message: "Handshake successful" } (device registration + device-linked event already fired by the token verification block above the match)
src/App.tsx Added listen('device-linked', ...) effect; shows toast notification for 3 seconds when a device connects
src/App.css Added .toast-notification styles — fixed-position top-right, green, animated fade-in

End-to-End Test Procedure (v0.3.0-test)

Pre-conditions

  • Desktop app running (dev or production build)
  • Mobile APK installed (release or debug with Metro)
  • Both devices on same WiFi or desktop hotspot

Steps

  1. Start Desktop, click Generate QR Code

    • Expected: QR code appears, multiple IPs shown below it
  2. On Mobile, go to Device Pairing → scan QR code

    • Mobile shows spinner ("Connecting to Desktop...")
    • Mobile sends handshake message immediately after ws.onopen
    • Desktop registers device, emits device-linked event
    • Expected (Desktop): green toast notification pops up at top-right: "Device linked: Mobile (x.x.x.x:PORT)"
    • Expected (Desktop device list): "Linked Devices (1)" appears immediately, no refresh needed
    • Expected (Desktop Testing panel): device count increments + log entry added
    • Expected (Mobile): "Device Linked!" alert appears only after Desktop sends handshake response
  3. Navigate to Testing tab on mobile

    • Expected: "Connected to Desktop" in green
    • Counter starts incrementing and appears on Desktop — same as before

Pass Criteria

  • "Device Linked!" alert on mobile appears only after Desktop sends handshake response
  • Desktop toast notification appears within ~1 second of QR scan
  • Desktop device list updates without clicking "Refresh Devices"
  • Desktop Testing panel device count increments on connection (no counter needed)
  • Counter still works after handshake (no regression)
  • If handshake times out (Desktop unreachable after connect), mobile shows "Connection Failed" instead of "Device Linked!"

v0.4.0-test — Connection Lifecycle Fixes

Status: Fixed ✅ Date: 2026-03-13 Scope: Stale connection state, auto-reconnect, duplicate device registrations, BLE section clarification


Problems Found

Bug 1 — Stale "Connected" State After Network Drop (Airplane Mode)

Root cause (Desktop): handle_websocket_connection() exited its loop on Message::Close or Err with no cleanup code. Disconnected devices were never removed from linked_devices and no event was emitted — so the Desktop UI showed phantom "connected" devices indefinitely.

Root cause (Mobile): The ws.onerror handler was only set before the connection opened (inside connectToSingle). After ws.onopen fired, the pre-open onerror was replaced with a connection-established one, but the onclose path was the only handler that cleared this.connected. An abrupt network drop (airplane mode) triggers an error on the socket, not a clean close — so this.connected stayed true.


Bug 2 — No Auto-Reconnect After Network Recovery

Root cause: DeviceLinkingService stored no credentials after a successful connect, and both onclose and onerror simply cleared state without scheduling a retry. After turning off airplane mode, the connection stayed dead and the user had to re-scan the QR code.


Bug 3 — Duplicate Devices on Reconnect (3 devices shown instead of 1)

Root cause: Device deduplication on Desktop used d.name == device.name where the name included the ephemeral TCP source port (e.g., Mobile (192.168.0.32:43008)). Every reconnect from the same phone used a different ephemeral port, so the dedup check always passed and a new entry was added. Combined with Bug 1 (old entries never removed), the list grew with each pairing attempt.


Bug 4 — "Bluetooth Discovery Test" Misleadingly Named

Root cause: The Testing Module's "Bluetooth Discovery Test" section was named and described as a test, but contains zero Bluetooth code. It is purely a CSS/RN animation that visualises what BLE will carry in Phase 3. The animation only activates after QR pairing succeeds, which confused the expectation that BLE would enable pairing without QR.


Changes Made

Desktop (apps/desktop/)

File Change
src-tauri/src/device_linking.rs Track registered_device_id in connection handler; on exit (clean or error), call retain() to remove device and emit device-disconnected event. Use stable device_id from handshake payload for dedup (d.id == device.id); if same device reconnects, update entry in-place instead of appending
src/components/TestingPanel.tsx Listen to device-disconnected event, decrement connectedDevices, log entry
modules/testing/desktop/src/components/TestingPanel.tsx Same as above (mirror file)

Mobile (apps/mobile/)

File Change
src/services/DeviceLinkingService.ts Stable deviceId (UUID) loaded from / persisted to AsyncStorage; sent as device_id in handshake payload. lastKnownCredentials stored on successful connect. Persistent onerror and onclose set after open — both call scheduleReconnect(). Exponential backoff reconnect: 2 s → 4 s → 8 s … 30 s max. onConnectionChange() subscription API for reactive UI. disconnect() cancels reconnect timer and clears credentials
src/screens/DevicePairingScreen.tsx No change — sendHandshake() already called; reconnect path calls it too

Testing Module (modules/testing/)

File Change
mobile/src/components/TestingScreen.tsx Subscribe to DeviceLinkingService.onConnectionChange() in useEffect so isConnected updates automatically without manual refresh. BLE section renamed to "BLE Discovery — Phase 3 Preview" with clear simulation disclaimer
desktop/src/components/TestingPanel.tsx device-disconnected listener. BLE section renamed to "BLE Discovery — Phase 3 Preview" with clear simulation disclaimer

End-to-End Test Procedure (v0.4.0-test)

Test 1 — Stale Connection Detection

  1. Link devices via QR → verify counter increments on Desktop
  2. Turn on Airplane Mode on mobile
  3. Expected within ~5 s:
    • Mobile: "Not connected" indicator updates automatically
    • Desktop: device-disconnected log entry appears, device count drops to 0

Test 2 — Auto-Reconnect

  1. (Continue from Test 1 or re-link)
  2. Turn off Airplane Mode on mobile
  3. Expected within ~2–8 s:
    • Mobile reconnects automatically (no QR scan)
    • Counter resumes on Desktop
    • Device count returns to 1

Test 3 — Deduplication

  1. Pair mobile via QR → verify 1 device shown
  2. Force-stop and re-launch mobile app
  3. Re-pair via QR scan
  4. Expected: Desktop shows exactly 1 device (not 2)

Test 4 — BLE Section

  1. Open Testing Module on both devices
  2. Verify section title reads "BLE Discovery — Phase 3 Preview"
  3. Verify description makes clear no Bluetooth is active

Pass Criteria

  • Airplane mode → device count drops to 0 automatically (both sides)
  • Network recovery → auto-reconnect within 30 s, counter resumes
  • Re-link after app restart → exactly 1 device shown
  • BLE section clearly labelled as Phase 3 preview / visual simulation

v0.5.0-test — BLE Auto-Discovery

Status: Implemented ✅ Date: 2026-03-14 Scope: Desktop BLE advertising (Windows) + Mobile BLE scanning → automatic WSS connection without QR


Overview

QR pairing remains the fallback. BLE auto-discovery means:

  1. Desktop starts advertising its IP + port over Bluetooth LE (WinRT, Windows-only for now)
  2. Mobile scans for Zelara BLE advertisements in the background
  3. Mobile auto-connects via the existing WSS/TLS path — no user action required
  4. TestingPanel shows a live BLE status badge and animates the IP-ball only for BLE-discovered connections

Changes Made

Desktop (apps/desktop/)

File Change
src-tauri/src/ble_advertising.rs New — WinRT BLE publisher; start_advertising(ip, port), stop_advertising(), BleStatus enum; non-Windows stub returns NotSupported. Service UUID not included in advertisement (128-bit UUID struct + manufacturer data + Flags exceeds 31-byte BLE legacy limit on Windows)
src-tauri/src/lib.rs Register BleAdvertisingState; auto-start BLE in setup hook; add start_ble_advertising, stop_ble_advertising, get_ble_status Tauri commands
src-tauri/Cargo.toml windows = { version = "0.58", features = ["Devices_Bluetooth_Advertisement", "Foundation_Collections", "Storage_Streams"] } (Windows-only target)
src-tauri/src/device_linking.rs DeviceInfo gains discovery_method: String; added is_ble_connection session flag (sticky — prevents token errors on subsequent messages); BLE connections skip token validation; device-disconnected event includes discovery_method
src/components/TestingPanel.tsx BLE status badge (advertising/idle/notSupported/error); ball animation only for BLE connections; discovery_method tracked per device; listens to ble-status-changed event; renamed section to "BLE Auto-Discovery"
src/App.tsx invoke('start_pairing_server') on mount — WSS/TLS server now auto-starts at launch alongside BLE, so BLE-discovered connections succeed immediately without the user opening the QR screen

Mobile (apps/mobile/)

File Change
src/services/BLEDiscoveryService.ts New — scans for company ID 0xFFFE in manufacturer data (no UUID filter — UUID not in advertisement); parses IP:port payload; calls DeviceLinkingService.connect() with discoveryMethod: 'ble'; restarts scan on disconnect. Updated: exponential backoff (5 s → 60 s) after WSS failure; waitingForPower guard prevents duplicate subscriptions when BLE toggles off; scan logs reduced to compact heartbeat every 10 devices
src/services/DeviceLinkingService.ts ConnectionInfo gains discoveryMethod?: 'ble' | 'qr'; connect() accepts optional discoveryMethod param; sendHandshake() includes discovery_method in payload; reconnect preserves discovery method
App.tsx BLEDiscoveryService.startScan() on mount, destroy() on unmount
package.json Added react-native-ble-plx: "^3.4.0"
android/app/src/main/AndroidManifest.xml BLE permissions (Android 12+: BLUETOOTH_SCAN, BLUETOOTH_CONNECT; Android < 12: BLUETOOTH, BLUETOOTH_ADMIN, ACCESS_FINE_LOCATION); android.hardware.bluetooth_le feature (not required)

BLE Packet Format

Service UUID: not included in advertisement — the 128-bit UUID AD structure (18 bytes) + manufacturer data (10 bytes) + auto-added Flags (3 bytes) = 31 bytes exactly, causing Start() to fail with E_INVALIDARG on Windows. Mobile scans without UUID filter and identifies Zelara packets by company ID 0xFFFE.

Manufacturer data (company ID 0xFFFE):

Bytes Content
[0] [1] Company ID low/high byte (0xFE 0xFF = 0xFFFE little-endian)
[2][5] IPv4 octets (big-endian)
[6] [7] Port number (big-endian u16)

Total: 8 bytes manufacturer data.


Security Model

BLE connections use proximity as trust — no pairing token required. Rationale:

  • BLE range (~10 m) implies physical proximity
  • WSS connection still requires same local network (WiFi)
  • The is_ble_connection flag is set once on handshake and remains true for the whole session — subsequent messages (counter, image tests) are not re-authenticated

QR connections always require the pairing token.


Test Procedure (v0.5.0-test)

Pre-conditions

  • Desktop: Windows with Bluetooth adapter enabled
  • Mobile: Android with Bluetooth enabled, on same WiFi as Desktop
  • Both apps freshly installed

Test 1 — BLE Auto-Connect (no QR)

  1. Start Desktop app — do not click "Generate QR Code"
  2. Check get_ble_status returns Advertising { ip, port } (verify in DevTools or logs)
  3. On Mobile, launch app (do not navigate to Device Pairing)
  4. Wait up to 10 s — mobile should auto-connect
  5. Expected:
    • Desktop: toast "Device linked: Mobile (BLE)"
    • Desktop Testing panel: device count = 1, BLE status badge shows "BLE Advertising"
    • Desktop Testing panel: IP-ball animation active
    • Mobile: counter starts incrementing on Desktop

Test 2 — BLE Section Shows Correct State

  1. Open Testing Module on Desktop (connected via BLE from Test 1)
  2. Verify section heading reads "BLE Auto-Discovery"
  3. Verify BLE status badge shows "BLE Advertising"
  4. Verify IP-ball animation is running

Test 3 — QR Pairing — Ball Does NOT Animate

  1. Disconnect Mobile
  2. Re-link via QR code (Device Pairing screen)
  3. Open Testing Module on Desktop
  4. Verify IP-ball animation is hidden (QR = no BLE animation)
  5. Verify BLE status badge still shows "BLE Advertising" (BLE stack unaffected by QR session)

Test 4 — Reconnect After Disconnect

  1. Connected via BLE (Test 1)
  2. Turn on Airplane Mode on Mobile
  3. Expected: Desktop device count drops to 0; BLE badge unchanged
  4. Turn off Airplane Mode
  5. Expected: Mobile re-discovers Desktop via BLE (or reconnects via stored credentials) within 30 s

Test 5 — No BLE Adapter (graceful degradation)

  1. Disable Bluetooth adapter on Desktop (or test on VM without BLE)
  2. Launch Desktop app
  3. Expected: get_ble_status returns NotSupported; no crash; QR pairing still works normally

Pass Criteria

  • Mobile auto-connects via BLE within ~10 s of launch (no QR)
  • Desktop BLE status badge shows "BLE Advertising" while advertising
  • IP-ball animation shows only for BLE connections, not QR
  • QR pairing still works as fallback
  • No crash on machines with no BLE adapter (NotSupported)
  • BLE re-discovery works after reconnect (mobile finds Desktop again after network drop)

Clone this wiki locally