diff --git a/README.md b/README.md
index 1442cee..d521461 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,8 @@ them as if they were one device:
**Requirements:** Android 10+ phone · Linux with BlueZ (Ubuntu/Debian, Fedora,
Arch, openSUSE) · Bluetooth (BLE) on both devices. Build tools (Rust/Node) are
-installed by the script itself.
+installed by the script itself. A Windows build is available too, cross-built
+from Linux — see [step 7](#7-laptop-windows--experimental).
### 1. Laptop (Linux)
@@ -220,6 +221,46 @@ lets Vortex keep one keyboard for the whole session, so you see the notification
once instead of on every crossing. The notification itself can also be switched
off: long-press it → turn off that category. Nothing else uses it.
+### 7. Laptop (Windows) — experimental
+
+There is no Windows installer yet. The app is **cross-built from Linux**: if
+you already build the Linux app on this machine, one more script produces the
+Windows `.exe` from the same checkout.
+
+```bash
+./install_windows.sh # toolchain + vortex-ui-tauri.exe
+./install_windows.sh --installer # also an NSIS setup.exe
+```
+
+It installs what is missing (clang/lld/llvm, the `x86_64-pc-windows-msvc` Rust
+target, `cargo-xwin`, node) and builds. The first run additionally downloads
+Microsoft's CRT and Windows SDK — a few hundred MB, once, into
+`~/.cache/cargo-xwin`; `cargo-xwin` accepts Microsoft's licence for that
+download on your behalf, so build on Windows instead if you would rather not.
+The result lands in
+`linux/ui-tauri/src-tauri/target/x86_64-pc-windows-msvc/release/`.
+
+Copy the `.exe` to the Windows machine and run it — there is nothing to
+install, it sits in the tray like the Linux build, and pairing is the same BLE
+flow from the same phone app.
+
+**What works:** pairing, reconnect, notification mirroring, clipboard, file
+transfer, Universal Control.
+
+**What does not:** screen mirror/cast, the continuity camera and the earbuds
+hand-off. Those are GStreamer/GTK/PulseAudio/BlueZ and have no Windows
+implementation, so they are compiled out rather than shipped broken.
+
+**Two things to expect on first run.** The binary is unsigned, so SmartScreen
+warns — *More info → Run anyway*. And on Windows 10 the window can open blank:
+that is a missing **WebView2 runtime** (Windows 11 ships it). Install
+Microsoft's Evergreen WebView2 Runtime and relaunch.
+
+> ⚠ **Experimental, and less tested than Linux.** The BLE layer here is a
+> separate implementation against a platform seam (WinRT rather than BlueZ),
+> and it has had far less real-world use. Treat it as a preview and please
+> report what breaks.
+
## 🔐 Security
All device-to-device traffic is **end-to-end encrypted**:
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d1b3f5a..476778f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -263,6 +263,36 @@
+
+
+
+
+
+
+
+
+
+
Unit)? = null
+
+ @Volatile
+ private var pendingPayload: AdvPayload? = null
/** Returns true while the phone should advertise in reconnect-seeking
* (LOW_LATENCY) mode — wired by VortexStack to "no live laptop GATT
@@ -61,100 +82,210 @@ class Advertiser(private val context: Context) {
}
/**
- * Start BLE advertising with the supplied [payload]. Used by both the
- * pairable-mode entry point and the trusted-presence rotation loop.
+ * Put [payload] on air.
+ *
+ * Reuses the advertising set whenever one is already running at the right
+ * interval, swapping only the service data. That in-place swap is the
+ * point: stopping and restarting a set makes the controller mint a fresh
+ * RPA, and a laptop that has just resolved our token then spends its whole
+ * connect timeout dialling an address we have already abandoned. With two
+ * remembered laptops the rotation loop tore the set down every
+ * [MULTIPLEX_DWELL_MS] — far shorter than any real connect — so the GATT
+ * link could never be established at all, and every BLE-only payload
+ * (clipboard text above all) was dropped in silence.
+ *
+ * A connect is dialled against an ADDRESS, not a token, so a stable
+ * address also means the laptop can still finish connecting after we have
+ * swapped on to the next peer's token — multiplexing no longer costs
+ * connectability.
+ *
+ * Privacy is unaffected: the controller keeps rotating the RPA on its own
+ * timer. We merely stop forcing a rotation every dwell.
*
* The advertiser stops itself if [stop] is called or the process exits.
*/
fun startWith(payload: AdvPayload, onResult: (StartResult) -> Unit) {
- if (activeCallback != null) {
- onResult(StartResult.Failed("already advertising"))
- return
- }
val advertiser = advertiser
if (advertiser == null) {
onResult(StartResult.Failed("bluetooth not available"))
return
}
+ val lowLatency = wantsLowLatency(payload)
+ val live = advertisingSet
+ // A pairing window always gets a FRESH set. It is a new, deliberately
+ // identity-exposing session, so inheriting the presence beacon's
+ // address would carry the old one straight into it; the swap is an
+ // optimisation for the 24/7 presence rotation, not for this.
+ if (live != null && activeLowLatency == lowLatency && !payload.flags.isPairable) {
+ swapPayload(live, payload, onResult)
+ } else {
+ startSet(advertiser, payload, lowLatency, onResult)
+ }
+ }
- val payloadBytes = payload.encode()
+ /**
+ * Whether [payload] should ride the dense (~100 ms) schedule.
+ *
+ * Pairable mode is a short user-opened window where discovery speed
+ * matters. Trusted-presence runs 24/7 and is normally ~250 ms, BUT while
+ * the laptop link is DOWN and recently lost ([fastModeProvider]) it goes
+ * dense too: the laptop's CONNECT_IND is answered at an advertising event,
+ * so a denser schedule directly cuts the walk-up reconnect (live-measured:
+ * screen-off connects ~11s vs ~1.5s screen-on — MIUI throttles background
+ * advertising hard, and a dense request lands in a faster throttle tier).
+ *
+ * `seeking` has to be its own term: [fastModeProvider] means "link is DOWN
+ * and was lost recently", but a seek deliberately keeps the current link UP
+ * (seek before release), so it evaluates false exactly when we most want
+ * the dense schedule — the user is walking to another machine right now.
+ */
+ private fun wantsLowLatency(payload: AdvPayload): Boolean =
+ payload.flags.isPairable || seeking || fastModeProvider?.invoke() == true
- // ADV_IND per spec §5.1.1: Flags + Service Data 128-bit AD only.
- // The Service Data field already carries the Vortex Service UUID, so
- // adding it via addServiceUuid() would duplicate it and overflow the
- // 31-byte legacy advertisement budget.
- val advertiseData = AdvertiseData.Builder()
- .addServiceData(ParcelUuid(Ble.VORTEX_SERVICE_UUID), payloadBytes)
+ /**
+ * ADV_IND per spec §5.1.1: Flags + Service Data 128-bit AD only.
+ * The Service Data field already carries the Vortex Service UUID, so
+ * adding it via addServiceUuid() would duplicate it and overflow the
+ * 31-byte legacy advertisement budget.
+ */
+ private fun advertiseDataFor(payload: AdvPayload): AdvertiseData =
+ AdvertiseData.Builder()
+ .addServiceData(ParcelUuid(Ble.VORTEX_SERVICE_UUID), payload.encode())
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(false)
.build()
- // SCAN_RSP carries the device's Bluetooth alias. This DEVIATES
- // from spec §5.1.2 ("user-set device name MUST NOT appear
- // here") — a deliberate per-user override because the alias
- // is needed to disambiguate when several Vortex phones appear
- // in the Linux scan list. The standard Bluetooth GAP layer
- // already exposes this alias during normal BT discovery; the
- // marginal extra exposure here is the time-window difference
- // (foreground-bound while no trust). User is aware and accepts.
- val scanResponse = AdvertiseData.Builder()
+ /**
+ * SCAN_RSP carries the device's Bluetooth alias. This DEVIATES from spec
+ * §5.1.2 ("user-set device name MUST NOT appear here") — a deliberate
+ * per-user override because the alias is needed to disambiguate when
+ * several Vortex phones appear in the Linux scan list. The standard
+ * Bluetooth GAP layer already exposes this alias during normal BT
+ * discovery; the marginal extra exposure here is the time-window
+ * difference (foreground-bound while no trust). User is aware and accepts.
+ */
+ private fun scanResponseData(): AdvertiseData =
+ AdvertiseData.Builder()
.setIncludeDeviceName(true)
.build()
- // Pairable mode is a short user-opened window where discovery speed
- // matters → LOW_LATENCY (~100 ms interval). Trusted-presence runs
- // 24/7 → BALANCED (~250 ms) by default, BUT while the laptop link
- // is DOWN and recently lost ([fastModeProvider]) it also runs
- // LOW_LATENCY: the laptop's CONNECT_IND is answered at an
- // advertising event, so a denser schedule directly cuts the
- // walk-up reconnect (live-measured: screen-off connects ~11s vs
- // ~1.5s screen-on — MIUI throttles background advertising hard,
- // and a LOW_LATENCY request lands in a faster throttle tier).
- // Re-evaluated at every 60s token rotation.
- // `seeking` has to be its own term: [fastModeProvider] means "link is
- // DOWN and was lost recently", but a seek deliberately keeps the
- // current link UP (seek before release), so it evaluates false exactly
- // when we most want the dense schedule — the user is walking to
- // another machine right now. This is the first rung of the §D5 ladder.
- val advertiseMode = if (payload.flags.isPairable ||
- seeking ||
- fastModeProvider?.invoke() == true
- ) {
- AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
- } else {
- AdvertiseSettings.ADVERTISE_MODE_BALANCED
+ /** Swap the service data on the live set — same set, same address. */
+ private fun swapPayload(
+ set: AdvertisingSet,
+ payload: AdvPayload,
+ onResult: (StartResult) -> Unit,
+ ) {
+ pendingPayload = payload
+ pendingResult = onResult
+ try {
+ set.setAdvertisingData(advertiseDataFor(payload))
+ } catch (e: SecurityException) {
+ pendingPayload = null
+ pendingResult = null
+ onResult(StartResult.Failed("missing BLUETOOTH_ADVERTISE permission: ${e.message}"))
}
- val settings = AdvertiseSettings.Builder()
- .setAdvertiseMode(advertiseMode)
+ }
+
+ /** Start a fresh advertising set (first time on air, or the interval changed). */
+ private fun startSet(
+ advertiser: android.bluetooth.le.BluetoothLeAdvertiser,
+ payload: AdvPayload,
+ lowLatency: Boolean,
+ onResult: (StartResult) -> Unit,
+ ) {
+ // Tear down any set running at the wrong interval first.
+ stop()
+
+ val parameters = AdvertisingSetParameters.Builder()
+ // Legacy mode keeps the exact ADV_IND + SCAN_RSP shape the Linux
+ // scanner already parses; only the control API changes.
+ .setLegacyMode(true)
.setConnectable(true)
- .setTimeout(0) // Vortex manages the window
- // HIGH (vs MEDIUM): the laptop hears us from farther away, so
- // the walk-up reconnect starts at the range edge instead of
- // near the desk. TX cost is per-advertising-event — small.
- .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .setScannable(true)
+ .setInterval(
+ if (lowLatency) {
+ AdvertisingSetParameters.INTERVAL_LOW
+ } else {
+ AdvertisingSetParameters.INTERVAL_MEDIUM
+ },
+ )
+ // HIGH (vs MEDIUM): the laptop hears us from farther away, so the
+ // walk-up reconnect starts at the range edge instead of near the
+ // desk. TX cost is per-advertising-event — small.
+ .setTxPowerLevel(AdvertisingSetParameters.TX_POWER_HIGH)
.build()
- val callback = object : AdvertiseCallback() {
- override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
- val mode = if (payload.flags.isPairable) "pairable" else "trusted-presence"
- Log.i(TAG, "advertise started: $mode, instance=${payloadBytes.copyOfRange(2, 10).toHexString()}")
- activePayload = payload
- onResult(StartResult.Started(payload))
+ val callback = object : AdvertisingSetCallback() {
+ override fun onAdvertisingSetStarted(
+ set: AdvertisingSet?,
+ txPower: Int,
+ status: Int,
+ ) {
+ val sink = pendingResult
+ val started = pendingPayload
+ pendingResult = null
+ pendingPayload = null
+ if (status != ADVERTISE_SUCCESS || set == null) {
+ Log.e(TAG, "advertise failed: ${errorCodeMessage(status)}")
+ setCallback = null
+ advertisingSet = null
+ activeLowLatency = null
+ sink?.invoke(StartResult.Failed(errorCodeMessage(status)))
+ return
+ }
+ advertisingSet = set
+ activeLowLatency = lowLatency
+ activePayload = started
+ val mode = if (started?.flags?.isPairable == true) "pairable" else "trusted-presence"
+ Log.i(
+ TAG,
+ "advertise started: $mode, instance=${started?.encode()?.copyOfRange(2, 10)?.toHexString()}",
+ )
+ started?.let { sink?.invoke(StartResult.Started(it)) }
+ }
+
+ override fun onAdvertisingDataSet(set: AdvertisingSet?, status: Int) {
+ val sink = pendingResult
+ val swapped = pendingPayload
+ pendingResult = null
+ pendingPayload = null
+ if (status != ADVERTISE_SUCCESS) {
+ Log.w(TAG, "advertise payload swap failed: ${errorCodeMessage(status)}")
+ sink?.invoke(StartResult.Failed(errorCodeMessage(status)))
+ return
+ }
+ activePayload = swapped
+ // Deliberately quieter than a start: this fires every dwell
+ // while multiplexing, and the address has NOT changed.
+ Log.i(
+ TAG,
+ "advertise payload swapped: instance=${swapped?.encode()?.copyOfRange(2, 10)?.toHexString()}",
+ )
+ swapped?.let { sink?.invoke(StartResult.Started(it)) }
}
- override fun onStartFailure(errorCode: Int) {
- val msg = errorCodeMessage(errorCode)
- Log.e(TAG, "advertise failed: $msg")
- activeCallback = null
- onResult(StartResult.Failed(msg))
+ override fun onAdvertisingSetStopped(set: AdvertisingSet?) {
+ advertisingSet = null
+ activeLowLatency = null
}
}
- activeCallback = callback
+ setCallback = callback
+ pendingPayload = payload
+ pendingResult = onResult
try {
- advertiser.startAdvertising(settings, advertiseData, scanResponse, callback)
+ advertiser.startAdvertisingSet(
+ parameters,
+ advertiseDataFor(payload),
+ scanResponseData(),
+ null,
+ null,
+ callback,
+ )
} catch (e: SecurityException) {
- activeCallback = null
+ setCallback = null
+ pendingPayload = null
+ pendingResult = null
onResult(StartResult.Failed("missing BLUETOOTH_ADVERTISE permission: ${e.message}"))
}
}
@@ -227,9 +358,10 @@ class Advertiser(private val context: Context) {
* remembered laptops cannot be addressed at once. With more than one peer
* the loop cycles them, dwelling [MULTIPLEX_DWELL_MS] on each, so any of
* them sees us within N × dwell — a few seconds, which is nothing on a
- * deliberate walk-up. With a single peer it does NOT cycle: restarting the
- * advertiser needlessly churns the RPA and costs battery, so the common
- * case keeps exactly the old one-advertise-per-bucket behaviour.
+ * deliberate walk-up. Cycling is free of the old cost: the loop swaps the
+ * service data on one long-lived advertising set rather than restarting it,
+ * so the Bluetooth address stays put and a laptop that has resolved its
+ * token can still complete a connect after the token has moved on.
*/
fun startPresenceLoop(
scope: CoroutineScope,
@@ -287,7 +419,9 @@ class Advertiser(private val context: Context) {
}
if (peers.size == 1) {
- stop()
+ // No stop() first: [startWith] reuses the live set and
+ // swaps the service data, so the bucket rotation no longer
+ // costs an address change either.
startWith(AdvPayload.trustedPresence(Presence.deriveToken(peers[0], bucket)), onStart)
// Sleep until ~5s past the next bucket boundary so we
// refresh just inside the new window — OR until a kick
@@ -302,7 +436,9 @@ class Advertiser(private val context: Context) {
// the peer set changed).
for (prs in peers) {
if (!isActive) break
- stop()
+ // Swap the token in place. Tearing the set down here is
+ // what used to re-randomise the RPA every dwell and made
+ // the laptop's connect time out every single attempt.
startWith(AdvPayload.trustedPresence(Presence.deriveToken(prs, bucket)), onStart)
val kicked = withTimeoutOrNull(MULTIPLEX_DWELL_MS) { rotationKick.receive() }
// A kick means the phase changed — abandon the pass
@@ -333,14 +469,18 @@ class Advertiser(private val context: Context) {
}
fun stop() {
- val cb = activeCallback ?: return
+ val cb = setCallback ?: return
try {
- advertiser?.stopAdvertising(cb)
+ advertiser?.stopAdvertisingSet(cb)
} catch (e: SecurityException) {
- Log.w(TAG, "stopAdvertising threw: ${e.message}")
+ Log.w(TAG, "stopAdvertisingSet threw: ${e.message}")
}
- activeCallback = null
+ setCallback = null
+ advertisingSet = null
+ activeLowLatency = null
activePayload = null
+ pendingResult = null
+ pendingPayload = null
Log.i(TAG, "advertise stopped")
}
@@ -351,16 +491,16 @@ class Advertiser(private val context: Context) {
stop()
}
- fun isAdvertising(): Boolean = activeCallback != null
+ fun isAdvertising(): Boolean = setCallback != null
fun activePayload(): AdvPayload? = activePayload
private fun errorCodeMessage(code: Int): String = when (code) {
- AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "ADVERTISE_FAILED_DATA_TOO_LARGE"
- AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS"
- AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "ADVERTISE_FAILED_ALREADY_STARTED"
- AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "ADVERTISE_FAILED_INTERNAL_ERROR"
- AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "ADVERTISE_FAILED_FEATURE_UNSUPPORTED"
+ AdvertisingSetCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "ADVERTISE_FAILED_DATA_TOO_LARGE"
+ AdvertisingSetCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS"
+ AdvertisingSetCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "ADVERTISE_FAILED_ALREADY_STARTED"
+ AdvertisingSetCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "ADVERTISE_FAILED_INTERNAL_ERROR"
+ AdvertisingSetCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "ADVERTISE_FAILED_FEATURE_UNSUPPORTED"
else -> "advertise error $code"
}
@@ -374,11 +514,16 @@ class Advertiser(private val context: Context) {
/** How long each peer's token stays on air during multiplexing.
*
* Long enough for a scanning laptop to catch several advertising
- * events (LOW_LATENCY ≈ 100 ms, BALANCED ≈ 250 ms), short enough that
- * N peers all get seen within a few seconds. Also the floor on how
- * often we restart the advertising set, which re-randomises the RPA —
- * cheaper dwells would inflate the laptop's BlueZ device cache and
- * feed the stale-RPA connect wedge. */
+ * events (dense ≈ 100 ms, normal ≈ 250 ms), short enough that N peers
+ * all get seen within a few seconds.
+ *
+ * This no longer bounds how often the RPA changes: a dwell now swaps
+ * the service data on the live advertising set instead of restarting
+ * it, so the address survives the whole pass. That is what closes the
+ * stale-RPA connect wedge — the laptop dials an address, not a token,
+ * so it can still finish connecting after we have moved on to the next
+ * peer. Before the swap, any dwell shorter than a connect timeout
+ * (~8 s) made the link unestablishable with two or more peers. */
private const val MULTIPLEX_DWELL_MS = 1_500L
/** Re-check interval while advertising is suspended (session live) or
diff --git a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt
index 1fb413f..ae07b71 100644
--- a/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/ble/GattServer.kt
@@ -456,7 +456,15 @@ class GattServer(
audioRecvCiphers[device.address] = recvCipher
audioRecvNonce[device.address] = 0L // fresh handshake → nonce starts at 0
deviceToPeerPub[device.address] = peerStaticPub.copyOf()
- Log.i(TAG, "registered audio session for peer=${peerHex.take(8)}… device=${device.address}")
+ // Worded for someone grepping logcat, not for the call site: this is the
+ // moment BLE becomes usable — IK done, ciphers installed, frames can
+ // flow. Its ABSENCE is the interesting signal (see the teardown branch
+ // of onConnectionStateChange), so the two lines are phrased as a pair.
+ sessionUpAddrs.add(device.address)
+ Log.i(
+ TAG,
+ "BLE session established with peer=${peerHex.take(8)}… device=${device.address}",
+ )
}
/** Which peer a connected device authenticated as, or null if IK has not
@@ -818,6 +826,21 @@ class GattServer(
private val connectedAddrs =
java.util.concurrent.ConcurrentHashMap.newKeySet()
+ /** When each live link came up, so a disconnect can report how long it
+ * lasted. Only read for logging — see the teardown branch of
+ * [onConnectionStateChange] for why that number is worth having. */
+ private val connectedSinceMs: ConcurrentHashMap = ConcurrentHashMap()
+
+ /** Addresses that reached a session ON THE CURRENT LINK.
+ *
+ * Deliberately not [deviceToPeerPub], which answers a different question.
+ * That map survives a disconnect on purpose (the address is the laptop's
+ * public one, so it stays meaningful), which makes it useless for "did
+ * THIS link get anywhere" — after one good session it reads non-null
+ * forever and every later failure looks like a success. Cleared on
+ * connect, set by [registerAudioSession], read once on teardown. */
+ private val sessionUpAddrs = java.util.concurrent.ConcurrentHashMap.newKeySet()
+
@Volatile
var lastDisconnectAtMs: Long = android.os.SystemClock.elapsedRealtime()
private set
@@ -880,9 +903,35 @@ class GattServer(
Log.i(TAG, "GATT $state device=${device?.address ?: "?"} status=$status")
if (newState == BluetoothProfile.STATE_CONNECTED && device != null) {
connectedAddrs.add(device.address)
+ connectedSinceMs[device.address] = android.os.SystemClock.elapsedRealtime()
+ sessionUpAddrs.remove(device.address) // fresh link, nothing proven yet
}
if (newState == BluetoothProfile.STATE_DISCONNECTED && device != null) {
connectedAddrs.remove(device.address)
+ // A link that never became a session is the failure worth
+ // shouting about, because it is otherwise INVISIBLE: the LAN
+ // transport carries everything, the UI still shows the laptop
+ // as connected, and the only trace is a "BLE session
+ // established" line that never arrives — which nobody notices,
+ // because you cannot grep for a line that is not there.
+ //
+ // That is not hypothetical. A one-sided LE bond made every
+ // connect die about 200 ms in, before a single ATT exchange,
+ // and it stayed hidden long enough for the phone's own counter
+ // to read "Bond loss detected, count: 1031". The lifetime is
+ // the tell: a handshake needs seconds, so anything sub-second
+ // means the link was torn down before Vortex got a word in.
+ val heldMs = connectedSinceMs.remove(device.address)
+ ?.let { android.os.SystemClock.elapsedRealtime() - it }
+ if (!sessionUpAddrs.remove(device.address)) {
+ Log.w(
+ TAG,
+ "BLE link to ${device.address} dropped after ${heldMs ?: -1}ms " +
+ "WITHOUT establishing a session — no IK completed. " +
+ "Sub-second here means something outside Vortex killed the " +
+ "link (check for bond/encryption failures in the BT stack log)",
+ )
+ }
if (connectedAddrs.isEmpty()) {
lastDisconnectAtMs = android.os.SystemClock.elapsedRealtime()
}
diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt
index 11506fa..b9f3cd7 100644
--- a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardAccess.kt
@@ -5,19 +5,34 @@ import android.content.Context
import android.os.Process
/**
- * Detects whether THIS app may read the clipboard in the BACKGROUND — i.e.
- * whether phone→laptop clipboard sync is fully AUTOMATIC, or falls back to the
- * manual Quick Settings tile.
+ * Whether the `READ_CLIPBOARD` AppOp is set to ALLOW for this app.
*
- * Android 10+ forbids background clipboard reads for sideloaded apps unless the
- * `READ_CLIPBOARD` AppOp is explicitly set to ALLOW (via ADB or root) — the same
- * mechanism other BLE phone-link apps use. The user grants it once with:
+ * **This is NOT sufficient for background clipboard reads, despite the name.**
+ * Measured on Android 16, LineageOS-based ROM (PJZ110, 2026-09-16): with the op
+ * set to ALLOW, the app doze-whitelisted, unrestricted in the background and
+ * running a foreground service, [ClipboardListener]'s change callback was never
+ * delivered once and the op was not noted for 3.8 days of ordinary copying.
*
- * adb shell appops set com.vortex.a3 READ_CLIPBOARD allow
+ * Near-AOSP is the important part: this is the stock platform gate, not an OEM
+ * clampdown, so it is what every reasonably current Android will do and not
+ * something a different ROM or a vendor workaround gets around.
*
- * This is a UX hint only; the real ground truth is whether a background read
- * actually returns content. A force-stop / reinstall can reset the op on some
- * OEMs, so the listener degrades gracefully regardless.
+ * The platform gates clipboard access on TWO conditions, not one: the caller
+ * must first qualify as allowed — by holding
+ * `android.permission.READ_CLIPBOARD_IN_BACKGROUND`, by being the default IME,
+ * or by owning the focused window — and only then is this AppOp consulted. A
+ * sideloaded Vortex is none of the three, and that permission is
+ * `signature|role`, so it cannot be granted by any route available to us
+ * (`pm grant` refuses it with "managed by role").
+ *
+ * So automatic background capture is not reachable by setting the op, and the
+ * ADB incantation this doc used to recommend does nothing on its own. The
+ * user-triggered paths are the real mechanism: the Quick Settings tile and the
+ * share sheet both run [ClipboardQuickSendActivity] in the FOREGROUND, which
+ * satisfies the focused-window condition and is why they work.
+ *
+ * Kept as a UX hint, and because the op is still the necessary half of the
+ * pair for any caller that does qualify.
*/
object ClipboardAccess {
/** True if the AppOp is set to ALLOW (background reads work → auto sync). */
diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt
new file mode 100644
index 0000000..e7881fe
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ClipboardOutbox.kt
@@ -0,0 +1,62 @@
+package com.vortex.a3.core.clipboard
+
+/**
+ * Clipboard text that could not go out over BLE, held for the LAN sync.
+ *
+ * Phone→laptop clipboard text is a BLE notify and nothing else. When the GATT
+ * link is down the notify fails at its first check and the text is gone — no
+ * retry, no fallback, no message. That is invisible in exactly the case it
+ * matters: a laptop can sit for hours with a perfectly healthy LAN session and
+ * a BLE link that never connected, and every copy the user makes is dropped
+ * while the UI says "Sending text to laptop…".
+ *
+ * File and image offers already solved this: they ride the bulk-sync done
+ * frame (see `LanServer.pendingOffersProvider`), so the link that works is the
+ * one that delivers. This is the same escape hatch for text.
+ *
+ * **One slot, latest wins.** The clipboard holds one primary item at a time,
+ * so a queue would only ever deliver stale content the user has already
+ * replaced — the same reasoning that makes [ClipboardSyncGuard] a single slot.
+ *
+ * **Bounded by age.** Text is only worth delivering while it is plausibly
+ * still what the user wants to paste; a copy from this morning arriving when
+ * the laptop finally reconnects is a surprise, not a feature.
+ */
+object ClipboardOutbox {
+
+ private val lock = Any()
+ private var text: String? = null
+ private var stashedAt: Long = 0L
+
+ /** Hold text the BLE path could not deliver. Replaces anything older. */
+ fun stash(value: String) {
+ if (value.isEmpty()) return
+ synchronized(lock) {
+ text = value
+ stashedAt = System.currentTimeMillis()
+ }
+ }
+
+ /**
+ * Take the pending text if there is any and it is still fresh, clearing
+ * the slot. Returns null otherwise.
+ *
+ * Taking on read (rather than after an ack) matches what the content is:
+ * the done frame carrying it is the last frame of a round that has already
+ * proved itself, and a clipboard is transient enough that re-announcing it
+ * on every subsequent round would be worse than dropping it once.
+ */
+ fun take(): String? = synchronized(lock) {
+ val pending = text ?: return@synchronized null
+ text = null
+ if (System.currentTimeMillis() - stashedAt > MAX_AGE_MS) null else pending
+ }
+
+ /** Drop anything pending — the content was delivered another way. */
+ fun clear() {
+ synchronized(lock) { text = null }
+ }
+
+ /** Beyond this, held text is stale enough that delivering it would surprise. */
+ private const val MAX_AGE_MS = 5 * 60 * 1000L
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt b/android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt
new file mode 100644
index 0000000..4467bd1
--- /dev/null
+++ b/android/app/src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt
@@ -0,0 +1,64 @@
+package com.vortex.a3.core.clipboard
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import com.vortex.a3.service.VortexService
+
+/**
+ * "Vortex" in the text-selection toolbar, beside Copy and Share.
+ *
+ * Select text anywhere, tap Vortex, and it lands on the laptop's clipboard —
+ * one tap, no share sheet and no app picker.
+ *
+ * This exists because AUTOMATIC capture is not reachable. Reading the clipboard
+ * in the background needs the caller to be the default IME, the focused window,
+ * or a holder of `READ_CLIPBOARD_IN_BACKGROUND` — which is `signature|role` and
+ * cannot be granted to a sideloaded app by any route (see [ClipboardAccess] for
+ * the measurements). So [ClipboardListener]'s callback is never delivered and
+ * every working path has to be one the user triggers.
+ *
+ * `ACTION_PROCESS_TEXT` is the cheapest of those by a wide margin: the system
+ * hands us the selected text directly in the intent, so there is no clipboard
+ * read to be refused and no invisible foreground trampoline to bounce through
+ * (unlike the Quick Settings tile, which needs [ClipboardQuickSendActivity] to
+ * take focus first).
+ */
+class ProcessTextActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ overridePendingTransition(0, 0)
+
+ val selected = intent
+ ?.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
+ ?.toString()
+ ?.trim()
+
+ if (selected.isNullOrEmpty()) {
+ Log.w(TAG, "process-text: empty selection — nothing to do")
+ Toast.makeText(this, "Nothing to send", Toast.LENGTH_SHORT).show()
+ } else {
+ // Same bus as the tile and the share sheet, so this inherits the
+ // cap, the chunking, the per-peer send and the LAN fallback for
+ // free. Length only in the log — never the content.
+ VortexService.clipboardBus.tryEmit(selected)
+ Log.i(TAG, "process-text: forwarded ${selected.length} chars to the laptop clipboard")
+ Toast.makeText(this, "Sending text to laptop…", Toast.LENGTH_SHORT).show()
+ }
+
+ // Deliberately NO setResult: returning EXTRA_PROCESS_TEXT would make the
+ // host app REPLACE the user's selection with whatever we sent back.
+ // Sending a copy to the laptop must never edit what they were reading —
+ // and in a read-only view (EXTRA_PROCESS_TEXT_READONLY) it would be
+ // silently discarded anyway, so the two cases behave the same.
+ finish()
+ overridePendingTransition(0, 0)
+ }
+
+ companion object {
+ private const val TAG = "VortexProcessText"
+ }
+}
diff --git a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt
index d6d64c2..4fdd421 100644
--- a/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/fs/FsServer.kt
@@ -363,7 +363,18 @@ class FsServer(
// concurrent ranged reads on one handle cannot interleave into each
// other's bytes. A thumbnailer firing parallel reads makes that a
// real case, not a theoretical one.
- Os.pread(pfd.fileDescriptor, buf, 0, want, r.offset)
+ //
+ // Looped: one pread may return less than asked — /sdcard is itself
+ // a FUSE mount — and the laptop's mount reads a short reply as "the
+ // file ends here", which cut photos off mid-file. Stop only at a
+ // real end (0) or once the request is filled.
+ var got = 0
+ while (got < want) {
+ val k = Os.pread(pfd.fileDescriptor, buf, got, want - got, r.offset + got)
+ if (k <= 0) break
+ got += k
+ }
+ got
} catch (e: ErrnoException) {
return if (e.errno == OsConstants.ESPIPE) {
// Some providers (cloud-backed documents) hand back a pipe,
diff --git a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
index 26baaa2..cb1650a 100644
--- a/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
+++ b/android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
@@ -176,6 +176,18 @@ class LanServer(
*/
var pendingOffersProvider: () -> List = { emptyList() }
+ /**
+ * Clipboard text the BLE notify could not deliver, for the same ride-along
+ * as [pendingOffersProvider].
+ *
+ * Phone→laptop clipboard text is a BLE notify and nothing else, so a
+ * laptop whose GATT link never connects loses every copy the user makes
+ * while this exchange completes cleanly every few seconds. The provider
+ * hands over at most one pending text (latest wins) and clears it, so the
+ * done frame delivers it on whichever link is actually up.
+ */
+ var pendingClipboardProvider: () -> String? = { null }
+
/**
* Capture tokens whose gallery rows this phone no longer has.
*
@@ -1008,6 +1020,24 @@ class LanServer(
status.put("deleted", org.json.JSONArray(deleted))
Log.i(TAG, "bulk-sync: reported ${deleted.size} deleted capture(s)")
}
+ // Clipboard text BLE could not deliver. Same
+ // reasoning as the offers above: send it on the
+ // link that works. Length only — never the content.
+ val clip = runCatching { pendingClipboardProvider() }
+ .getOrElse {
+ Log.w(TAG, "pending-clipboard provider threw: ${it.message}")
+ null
+ }
+ if (!clip.isNullOrEmpty()) {
+ status.put(
+ "clipboard",
+ org.json.JSONObject().apply {
+ put("text", clip)
+ put("ts", System.currentTimeMillis())
+ },
+ )
+ Log.i(TAG, "bulk-sync: carried clipboard text (${clip.length} chars)")
+ }
lockedSealAndWrite(
FrameType.BULK_SYNC, 0x02,
status.toString().toByteArray(Charsets.UTF_8),
@@ -1020,8 +1050,14 @@ class LanServer(
aeadOpen(pair.receiver, frame.payload)
}.getOrNull()
if (plain == null) {
- Log.w(TAG, "fs: reply AEAD decrypt failed")
- continue
+ // Noise nonces are implicit and a failed open
+ // does not advance ours, so one lost frame
+ // means every later one fails too. Hang up
+ // rather than drop it quietly: the laptop
+ // sees the socket close and reopens at once,
+ // instead of timing out request after request.
+ Log.w(TAG, "fs: reply AEAD decrypt failed; closing the session")
+ break
}
// This socket is demonstrably the laptop's FS
// session, so it is the one to send our own
@@ -1043,8 +1079,12 @@ class LanServer(
aeadOpen(pair.receiver, frame.payload)
}.getOrNull()
if (plain == null) {
- Log.w(TAG, "fs: AEAD decrypt failed")
- continue
+ // Same as the reply branch above: a session
+ // that has lost nonce sync never gets it back,
+ // and staying silent left the laptop's file
+ // manager waiting out a 20 s timeout per op.
+ Log.w(TAG, "fs: AEAD decrypt failed; closing the session")
+ break
}
val serve = fsServe
if (serve == null) {
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
index 2510504..e27d8be 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
@@ -396,9 +396,44 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
feature("lanServer") { startLanServer(identity) } // mDNS + TCP IK + AppState sync
feature("outboxDrain") { startOutboxDrain() }
+ feature("bondHygiene") { clearStaleLaptopBonds() } // one-sided LE bonds kill BLE
return true
}
+ /**
+ * Drop any Bluetooth bond this phone still holds for a trusted laptop.
+ *
+ * Vortex is bondless on both sides. The laptop deliberately skips
+ * `Device::pair()` ("NOTE: no BT bond here" in `pairing.rs`), no
+ * characteristic in [com.vortex.a3.core.ble.GattServer] asks for an
+ * encrypted link, and the laptop advertises a public static address so
+ * there is no IRK worth having. A bond is therefore never an asset here.
+ *
+ * It is a liability, because it can only ever be one-sided. A stored LTK
+ * the laptop does not share can never be used: on each LE connect we try to
+ * encrypt, the central answers by pairing instead, we read that as bond loss
+ * and drop the ACL with HCI_ERR_AUTH_FAILURE about 200 ms in, before a
+ * single ATT exchange. The result is that BLE silently never works at all,
+ * while LAN carries on and hides it — the failure looks like "the laptop
+ * just never connects over Bluetooth", with nothing in our own log to say
+ * why. Live-measured 2026-09-17: every connect died that way for hours, the
+ * phone's counter reading "Bond loss detected, count: 1031".
+ *
+ * Android hides profile-less LE bonds from the Settings UI, so the user
+ * cannot clear this by hand; it has to happen here. Running it on every
+ * start (rather than only at pairing) is what heals a phone bonded by an
+ * older build, or by someone connecting from the desktop's Bluetooth panel.
+ * Cheap either way: [com.vortex.a3.core.ble.BondCleaner.removeBond] no-ops
+ * on BOND_NONE, and the address list is the trusted peers we already read.
+ */
+ private fun clearStaleLaptopBonds() {
+ val adapter = service.getSystemService(BluetoothManager::class.java)?.adapter ?: return
+ for (peer in peerStore.list()) {
+ val addr = peerStore.loadPeerBtAddr(peer.peerStaticPub) ?: continue
+ com.vortex.a3.core.ble.BondCleaner.removeBond(adapter, addr)
+ }
+ }
+
/** Send anything buffered for each trusted peer. Returns immediately when
* there is nothing queued. */
internal suspend fun flushNotificationOutbox() {
@@ -1266,6 +1301,15 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
lan.deletedCapturesProvider = {
com.vortex.a3.core.media.CaptureLedger.deletedTokens(ctx)
}
+ // Clipboard text the BLE notify could not deliver, so a laptop with a
+ // dead GATT link but a live LAN session still gets what was copied.
+ lan.pendingClipboardProvider = {
+ if (com.vortex.a3.core.clipboard.ClipboardSyncSetting.isEnabled()) {
+ com.vortex.a3.core.clipboard.ClipboardOutbox.take()
+ } else {
+ null
+ }
+ }
lanServer = lan
// Let code with no handle on the stack ship a snapshot immediately —
// the accessibility service reporting an input-focus change, which the
diff --git a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt
index 0518b27..c53127f 100644
--- a/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt
+++ b/android/app/src/main/java/com/vortex/a3/service/VortexStackClipboard.kt
@@ -29,22 +29,49 @@ internal fun VortexStack.startClipboardOutbound() {
trimmed
}
val utf8Len = capped.toByteArray(Charsets.UTF_8).size
+ // Whether the text actually reached a peer. Every send below is a
+ // BLE notify that returns false on a down link, and the old code
+ // ignored that: it logged "sent chunked" and let the share sheet
+ // toast "Sending text to laptop…" while the bytes went nowhere.
+ var delivered = false
if (utf8Len <= com.vortex.a3.core.clipboard.ClipboardText.MAX_SINGLE_FRAME_TEXT_BYTES) {
val json = clipboardJsonBytes(capped)
for (peer in peerStore.list()) {
- gattServer?.sendClipboardEncrypted(peer.peerStaticPub, json)
+ if (gattServer?.sendClipboardEncrypted(peer.peerStaticPub, json) == true) {
+ delivered = true
+ }
}
} else {
// Long text → chunk over CLIPBOARD_TEXT, paced so the BLE
// notify queue doesn't drop frames (same 12ms as images).
val chunks = com.vortex.a3.core.clipboard.ClipboardText.buildChunks(capped)
for (peer in peerStore.list()) {
+ // A peer counts as delivered only if EVERY chunk got out:
+ // a partial burst reassembles into nothing on the far side,
+ // so it must not suppress the LAN fallback.
+ var whole = true
for (chunk in chunks) {
- gattServer?.sendClipboardTextChunkEncrypted(peer.peerStaticPub, chunk)
+ if (gattServer?.sendClipboardTextChunkEncrypted(peer.peerStaticPub, chunk) != true) {
+ whole = false
+ }
kotlinx.coroutines.delay(12)
}
+ if (whole) delivered = true
}
- Log.i(VortexStack.TAG, "clipboard: long text sent chunked ($utf8Len bytes, ${chunks.size} chunks)")
+ if (delivered) {
+ Log.i(VortexStack.TAG, "clipboard: long text sent chunked ($utf8Len bytes, ${chunks.size} chunks)")
+ }
+ }
+ if (!delivered) {
+ // Hand it to the LAN sync rather than dropping it. The done
+ // frame goes out on every bulk-sync round, so with BLE down and
+ // the LAN session up this arrives within a heartbeat instead of
+ // never. Content is never logged — only its size.
+ com.vortex.a3.core.clipboard.ClipboardOutbox.stash(capped)
+ Log.w(
+ VortexStack.TAG,
+ "clipboard text couldn't go out over BLE (link down?); queued for the LAN sync ($utf8Len bytes)",
+ )
}
}
}
diff --git a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt
index 3102f12..ea38070 100644
--- a/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt
+++ b/android/app/src/main/java/com/vortex/a3/ui/MainActivityPairing.kt
@@ -71,13 +71,13 @@ internal fun MainActivity.wirePairingOrchestrator(identity: IdentityRecord) {
peerName = outcome.peerName,
)
)
- try {
- if (outcome.device.bondState == android.bluetooth.BluetoothDevice.BOND_NONE) {
- outcome.device.createBond()
- }
- } catch (e: Exception) {
- android.util.Log.w("Pairing", "createBond: ${e.message}")
- }
+ // Deliberately NO createBond() here: the laptop skips
+ // `Device::pair()` on purpose, so bonding would leave us
+ // holding an LTK it has never had — which kills every later
+ // BLE connect. VortexStack.clearStaleLaptopBonds() has the
+ // mechanism and the measurements, and clears bonds that
+ // arrive from elsewhere.
+
// Remember the laptop's BD_ADDR so Forget can clear any BT
// bond for it later (see PeerStore.loadPeerBtAddr). The
// central's address here is the laptop's public static
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 9ba35bf..95f4c41 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2,6 +2,9 @@
VortexVortex Clipboard
+
+ VortexLets your paired laptop control this phone (tap, swipe, Back/Home) while screen mirroring. No data is collected.Open on this phoneOpen on phone
diff --git a/android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt b/android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt
new file mode 100644
index 0000000..181492b
--- /dev/null
+++ b/android/app/src/test/java/com/vortex/a3/core/clipboard/ClipboardOutboxTest.kt
@@ -0,0 +1,48 @@
+package com.vortex.a3.core.clipboard
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+/**
+ * The LAN fallback for clipboard text the BLE notify could not deliver.
+ *
+ * Phone→laptop clipboard text used to be a BLE notify and nothing else, so a
+ * laptop with a dead GATT link lost every copy in silence. These pin the two
+ * properties the fallback relies on.
+ */
+class ClipboardOutboxTest {
+
+ @BeforeEach
+ fun reset() = ClipboardOutbox.clear()
+
+ @Test
+ fun `text survives until it is taken, then the slot is empty`() {
+ ClipboardOutbox.stash("copied on the phone")
+ assertEquals("copied on the phone", ClipboardOutbox.take())
+ // Taken once: a second bulk-sync round must not re-deliver it and
+ // overwrite whatever the user has copied since.
+ assertNull(ClipboardOutbox.take())
+ }
+
+ @Test
+ fun `latest copy wins over an undelivered older one`() {
+ ClipboardOutbox.stash("first")
+ ClipboardOutbox.stash("second")
+ // A clipboard holds one item; delivering "first" would paste content
+ // the user has already replaced.
+ assertEquals("second", ClipboardOutbox.take())
+ }
+
+ @Test
+ fun `nothing pending yields nothing`() {
+ assertNull(ClipboardOutbox.take())
+ }
+
+ @Test
+ fun `an empty copy is never stashed`() {
+ ClipboardOutbox.stash("")
+ assertNull(ClipboardOutbox.take())
+ }
+}
diff --git a/android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt b/android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt
new file mode 100644
index 0000000..b34bdef
--- /dev/null
+++ b/android/app/src/test/java/com/vortex/a3/core/clipboard/ProcessTextContractTest.kt
@@ -0,0 +1,55 @@
+package com.vortex.a3.core.clipboard
+
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.io.File
+
+/**
+ * Static guards on the text-selection-toolbar entry point.
+ *
+ * Both checks are for mistakes that are invisible in review and only show up
+ * on a device: one silently removes Vortex from the toolbar, the other
+ * silently edits the user's document.
+ */
+class ProcessTextContractTest {
+
+ private val manifest = File("src/main/AndroidManifest.xml")
+ private val activity =
+ File("src/main/java/com/vortex/a3/core/clipboard/ProcessTextActivity.kt")
+
+ @Test
+ fun `the toolbar entry point is declared and reachable`() {
+ val xml = manifest.readText()
+ assertTrue(xml.contains("ProcessTextActivity"), "activity missing from the manifest")
+ assertTrue(
+ xml.contains("android.intent.action.PROCESS_TEXT"),
+ "no PROCESS_TEXT filter — Vortex would not appear in the selection toolbar",
+ )
+ // Not exported = the system cannot launch it, and the toolbar item
+ // silently never appears.
+ val block = xml.substringAfter("ProcessTextActivity").substringBefore("")
+ assertTrue(
+ block.contains("android:exported=\"true\""),
+ "the activity must be exported for the system to launch it",
+ )
+ assertTrue(
+ block.contains("android.intent.category.DEFAULT"),
+ "an implicit intent needs category DEFAULT to resolve",
+ )
+ }
+
+ /**
+ * Returning `EXTRA_PROCESS_TEXT` in the result makes the HOST app replace
+ * the user's selection with whatever we send back. Sending a copy to the
+ * laptop must never edit what they were reading.
+ */
+ @Test
+ fun `the activity never writes back over the user's selection`() {
+ val src = activity.readText()
+ assertFalse(
+ src.contains("setResult("),
+ "setResult would overwrite the selected text in the host app",
+ )
+ }
+}
diff --git a/install_windows.sh b/install_windows.sh
new file mode 100755
index 0000000..956b667
--- /dev/null
+++ b/install_windows.sh
@@ -0,0 +1,210 @@
+#!/usr/bin/env bash
+#
+# install_windows.sh — cross-build the Vortex laptop app for Windows, from Linux.
+#
+# Produces `vortex-ui-tauri.exe` (and optionally an NSIS installer) without ever
+# touching a Windows machine. Run it on the same checkout you build Linux from:
+#
+# ./install_windows.sh # deps + .exe
+# ./install_windows.sh --installer # also the NSIS setup.exe
+# ./install_windows.sh --skip-deps # I already have the toolchain
+#
+# WHY the MSVC target and not `x86_64-pc-windows-gnu`, which needs no downloads:
+# the app talks to WinRT through the `windows` crate (Bluetooth LE lives in
+# Devices_Bluetooth), and Tauri links WebView2. Both are built against the MSVC
+# ABI; the GNU target is a different ABI and does not link them. So the MSVC
+# target it is, which means we need Microsoft's CRT and SDK — that is the one
+# thing a Linux box does not have, and exactly what `cargo-xwin` fetches.
+#
+# What gets installed:
+# • clang / lld / llvm — cargo-xwin drives clang-cl, lld-link and llvm-rc
+# • rustup target x86_64-pc-windows-msvc
+# • cargo-xwin — downloads the MSVC CRT + Windows SDK on first build
+# • node/npm — only if missing (same reasoning as install-deps.sh)
+# • makensis — ONLY with --installer
+#
+# What you get is a Windows build of everything except the Linux-only
+# subsystems. Screen mirror/cast, the continuity camera and the earbuds
+# hand-off are GStreamer/GTK/PulseAudio/BlueZ and are compiled out (see the
+# "Linux-only subsystems" gate in linux/ui-tauri/src-tauri/src/lib.rs). Pair,
+# reconnect, notifications, clipboard, file transfer and Universal Control all
+# build.
+set -uo pipefail
+
+GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; BOLD='\033[1m'; NC='\033[0m'
+ok() { printf "${GREEN}✓ %s${NC}\n" "$1"; }
+warn() { printf "${YELLOW}⚠ %s${NC}\n" "$1"; }
+err() { printf "${RED}✗ %s${NC}\n" "$1"; }
+info() { printf "${BOLD}▶ %s${NC}\n" "$1"; }
+
+REPO="$(cd "$(dirname "$0")" && pwd)"
+UI="$REPO/linux/ui-tauri"
+TARGET="x86_64-pc-windows-msvc"
+OUT_DIR="$UI/src-tauri/target/$TARGET/release"
+EXE="$OUT_DIR/vortex-ui-tauri.exe"
+
+export PATH="$HOME/.cargo/bin:$PATH"
+
+SKIP_DEPS=0
+WANT_INSTALLER=0
+ASSUME_YES=1
+for a in "$@"; do
+ case "$a" in
+ --skip-deps) SKIP_DEPS=1 ;;
+ --installer) WANT_INSTALLER=1 ;;
+ --ask) ASSUME_YES=0 ;;
+ --yes) ASSUME_YES=1 ;;
+ -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ esac
+done
+
+# ── 0. system dependencies ────────────────────────────────────────────────────
+if [ "$SKIP_DEPS" -eq 0 ]; then
+ PM=""
+ for c in apt-get dnf pacman zypper; do
+ if command -v "$c" >/dev/null 2>&1; then PM="$c"; break; fi
+ done
+
+ SUDO=""
+ [ "$(id -u)" -ne 0 ] && SUDO="sudo"
+
+ YES_FLAG=""
+ [ "$ASSUME_YES" -eq 1 ] && case "$PM" in
+ apt-get|dnf|zypper) YES_FLAG="-y" ;;
+ pacman) YES_FLAG="--noconfirm" ;;
+ esac
+
+ # clang-cl, lld-link and llvm-rc are the three tools cargo-xwin shells out to.
+ # They are split across packages differently per distro, hence the mapping
+ # rather than one name.
+ declare -a PKGS=()
+ case "$PM" in
+ apt-get) PKGS=(clang lld llvm) ;;
+ dnf) PKGS=(clang lld llvm) ;;
+ pacman) PKGS=(clang lld llvm) ;;
+ zypper) PKGS=(clang lld llvm) ;;
+ "") warn "No supported package manager found — install clang, lld and llvm by hand." ;;
+ esac
+
+ if [ -n "$PM" ] && [ "${#PKGS[@]}" -gt 0 ]; then
+ info "installing the cross toolchain (clang / lld / llvm)…"
+ case "$PM" in
+ apt-get) $SUDO apt-get update -qq; $SUDO apt-get install $YES_FLAG "${PKGS[@]}" ;;
+ dnf) $SUDO dnf install $YES_FLAG --skip-broken "${PKGS[@]}" ;;
+ pacman) $SUDO pacman -Sy $YES_FLAG --needed "${PKGS[@]}" ;;
+ zypper) $SUDO zypper install $YES_FLAG "${PKGS[@]}" ;;
+ esac || warn "toolchain install had issues — continuing; the build will say what's missing."
+ fi
+
+ # Node only when absent: installing it unconditionally is what breaks a box
+ # that already has NodeSource node (see the note in packaging/install-deps.sh).
+ if ! command -v node >/dev/null 2>&1; then
+ info "installing node/npm…"
+ case "$PM" in
+ apt-get) $SUDO apt-get install $YES_FLAG nodejs npm ;;
+ dnf) $SUDO dnf install $YES_FLAG nodejs npm ;;
+ pacman) $SUDO pacman -Sy $YES_FLAG --needed nodejs npm ;;
+ zypper) $SUDO zypper install $YES_FLAG nodejs npm ;;
+ esac || warn "node install failed — install node ≥18 yourself and re-run."
+ fi
+
+ if ! command -v rustup >/dev/null 2>&1 && ! command -v cargo >/dev/null 2>&1; then
+ info "installing Rust via rustup (distro Rust is usually too old for Tauri)…"
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+ [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
+ fi
+fi
+
+command -v cargo >/dev/null 2>&1 || { err "cargo not on PATH — install Rust and re-run."; exit 1; }
+
+# ── 1. the Windows target ─────────────────────────────────────────────────────
+if rustup target list --installed 2>/dev/null | grep -qx "$TARGET"; then
+ ok "rust target $TARGET already installed"
+else
+ info "adding rust target $TARGET…"
+ rustup target add "$TARGET" || { err "could not add $TARGET"; exit 1; }
+fi
+
+# ── 2. cargo-xwin ─────────────────────────────────────────────────────────────
+# First build downloads Microsoft's CRT and Windows SDK into ~/.cache/cargo-xwin
+# (a few hundred MB, once). cargo-xwin accepts the Microsoft licence on your
+# behalf for that download — if that is not acceptable to you, stop here and
+# build on Windows instead.
+if command -v cargo-xwin >/dev/null 2>&1; then
+ ok "cargo-xwin already installed ($(cargo-xwin --version 2>/dev/null | head -1))"
+else
+ info "installing cargo-xwin (compiles from source, a few minutes)…"
+ cargo install cargo-xwin || { err "cargo install cargo-xwin failed"; exit 1; }
+fi
+
+# ── 3. NSIS, only when asked ──────────────────────────────────────────────────
+if [ "$WANT_INSTALLER" -eq 1 ] && ! command -v makensis >/dev/null 2>&1; then
+ info "installing NSIS (makensis) for the installer…"
+ case "${PM:-}" in
+ apt-get) $SUDO apt-get install $YES_FLAG nsis ;;
+ dnf) $SUDO dnf install $YES_FLAG mingw32-nsis ;;
+ zypper) $SUDO zypper install $YES_FLAG mingw32-nsis ;;
+ # Arch/CachyOS: nsis is AUR-only, so this needs an AUR helper. Not fatal —
+ # the .exe below is built either way.
+ pacman)
+ if command -v paru >/dev/null 2>&1; then paru -S --needed --noconfirm nsis
+ elif command -v yay >/dev/null 2>&1; then yay -S --needed --noconfirm nsis
+ else warn "nsis is in the AUR; install it with an AUR helper (e.g. 'paru -S nsis')."
+ fi ;;
+ esac || warn "NSIS install failed — the .exe will still be built."
+fi
+
+# ── 4. UI dependencies ────────────────────────────────────────────────────────
+# Same pnpm-then-npm fallback as install_linux.sh: npm needs --legacy-peer-deps
+# because one dep declares an optional peer on a newer vite than we pin, and
+# npm hard-fails the whole install on it where pnpm does not.
+info "installing UI dependencies…"
+( cd "$UI"
+ if command -v pnpm >/dev/null 2>&1; then
+ pnpm install --frozen-lockfile || pnpm install
+ else
+ npm install --legacy-peer-deps
+ fi ) || { err "UI dependency install failed"; exit 1; }
+
+# ── 5. build ──────────────────────────────────────────────────────────────────
+# Build the Tauri APP, never `--workspace`. The daemon is consumed as a LIBRARY
+# and cross-compiles fine, but its binary (daemon/src/main.rs, `vortex-l3d`) is
+# a Linux CLI that uses bluer unconditionally and cannot build for Windows —
+# asking for the workspace just fails on a binary nobody wants here.
+BUNDLE_ARGS=(--no-bundle)
+if [ "$WANT_INSTALLER" -eq 1 ] && command -v makensis >/dev/null 2>&1; then
+ BUNDLE_ARGS=(--bundles nsis)
+fi
+
+info "cross-building for $TARGET (first run also downloads the MSVC CRT + SDK)…"
+( cd "$UI" && npm run tauri build -- \
+ --runner cargo-xwin --target "$TARGET" "${BUNDLE_ARGS[@]}" ) || {
+ err "build failed"
+ exit 1
+}
+
+# lld-link prints a wall of LNK4099 "cannot use debug info for libcmt.lib(…)"
+# while linking. That is Microsoft shipping its CRT without the matching PDBs,
+# not a problem with this build — the binary is complete and correct.
+
+# ── 6. report ─────────────────────────────────────────────────────────────────
+[ -f "$EXE" ] || { err "build reported success but no .exe at $EXE"; exit 1; }
+echo
+ok "Windows binary: $EXE"
+file "$EXE" 2>/dev/null | sed 's/^/ /'
+SETUP="$(ls "$OUT_DIR"/bundle/nsis/*-setup.exe 2>/dev/null | head -1)"
+[ -n "$SETUP" ] && ok "Windows installer: $SETUP"
+echo
+info "On the Windows machine:"
+echo " • Copy the .exe (or run the installer) and launch it — no install step"
+echo " is needed for the bare .exe; it sits in the tray like the Linux build."
+echo " • Windows 11 already ships the WebView2 runtime. On Windows 10 without"
+echo " it the window opens blank: install the Evergreen WebView2 Runtime"
+echo " from Microsoft, then relaunch."
+echo " • Pair from the phone exactly as with Linux — same BLE + Noise flow."
+[ "$WANT_INSTALLER" -eq 1 ] && [ -z "$SETUP" ] && \
+ warn "--installer was requested but no setup.exe was produced (makensis missing?)."
+echo
+warn "Cross-built binaries are UNSIGNED. SmartScreen will warn on first run"
+warn "(\"More info\" → \"Run anyway\"). Signing needs a certificate and is set up"
+warn "via bundler > windows > sign_command in tauri.conf.json."
diff --git a/linux/daemon/src/core/audio_lan_session.rs b/linux/daemon/src/core/audio_lan_session.rs
index 30035bc..13a9eb2 100644
--- a/linux/daemon/src/core/audio_lan_session.rs
+++ b/linux/daemon/src/core/audio_lan_session.rs
@@ -164,6 +164,9 @@ pub async fn start_initiator_session(
.to_json()
.map_err(|e| format!("frame json: {e}"))?;
let mut out = vec![0u8; plain.len() + 16];
+ // Write half locked BEFORE sealing, so seal order is wire
+ // order — see `fs_lan::open_session` for the desync otherwise.
+ let mut w = writer_w.lock().await;
let n = {
let mut t = transport_w.lock().await;
t.write_message(&plain, &mut out)
@@ -171,7 +174,6 @@ pub async fn start_initiator_session(
};
let frame = Frame::new(ty::AUDIO_OP, 0x01, out[..n].to_vec());
let bytes = frame.encode();
- let mut w = writer_w.lock().await;
w.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?;
w.flush().await.map_err(|e| format!("tcp flush: {e}"))?;
Ok(())
diff --git a/linux/daemon/src/core/fs_lan.rs b/linux/daemon/src/core/fs_lan.rs
index 5fc829f..86b98b6 100644
--- a/linux/daemon/src/core/fs_lan.rs
+++ b/linux/daemon/src/core/fs_lan.rs
@@ -135,22 +135,47 @@ pub async fn open_session(
let writer: FsLanWriter = Arc::new(move |ty_byte: u8, sub: u8, plain: Vec| {
let transport = transport.clone();
let writer_half = writer_half.clone();
- Box::pin(async move {
+ // Noise nonces are implicit: the phone opens the k-th frame it receives
+ // with nonce k, and a failed open does not advance it. So every nonce
+ // sealed here must reach the wire, in sealing order, exactly once — a
+ // frame that is sealed and then not sent leaves the phone one nonce
+ // behind for the rest of the session: every later request is "AEAD
+ // decrypt failed" there and a 20 s timeout here, which is what froze
+ // Dolphin on DCIM/Camera.
+ //
+ // Two things used to break that. The seal happened under the transport
+ // lock and the write under a separate one, with an `await` between
+ // them; and callers DO drop this future part-way — `Vfs::read_range`
+ // abandons its other in-flight reads on the first error, EOF or short
+ // read — so a read parked on the write lock behind a 48 KiB write lost
+ // its already-sealed frame. Hence:
+ //
+ // * the write half is locked BEFORE sealing and held until the frame is
+ // out, so sealing order is wire order (as the BLE writers in
+ // `audio_signal` do);
+ // * the whole thing runs as its own task, so a caller that stops
+ // waiting cannot cut it off between seal and write, or half-way
+ // through `write_all` (which would corrupt the framing instead).
+ //
+ // The transport lock itself stays brief, so the read loop is not held
+ // up behind a large write.
+ let job = tokio::spawn(async move {
if plain.len() + 16 > MAX_FRAME_PAYLOAD {
return Err(format!("frame too large: {}", plain.len()));
}
let mut out = vec![0u8; plain.len() + 16];
+ let mut w = writer_half.lock().await;
let n = {
let mut t = transport.lock().await;
t.write_message(&plain, &mut out)
.map_err(|e| format!("aead seal: {e}"))?
};
let bytes = Frame::new(ty_byte, sub, out[..n].to_vec()).encode();
- let mut w = writer_half.lock().await;
w.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?;
w.flush().await.map_err(|e| format!("tcp flush: {e}"))?;
Ok(())
- })
+ });
+ Box::pin(async move { job.await.map_err(|e| format!("fs-lan write task: {e}"))? })
});
tracing::info!(%addr, "fs-lan: session up");
@@ -216,3 +241,168 @@ async fn read_sealed(
};
Ok(Some(Frame::new(header[0], header[1], plain[..n].to_vec())))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::core::crypto::noise::{prologue_with_prs, NOISE_IK};
+ use crate::core::crypto::x25519::public_from_private;
+ use tokio::net::TcpListener;
+ use tokio::sync::oneshot;
+
+ /// A listener whose accepted sockets keep a small, fixed receive buffer.
+ ///
+ /// Left to autotune, Linux grows a loopback receive buffer up to
+ /// `tcp_rmem`'s maximum — 32 MiB on CachyOS — which can swallow everything
+ /// the filler below sends, so nothing jams and the test proves nothing.
+ /// Setting the size explicitly turns autotuning off, and accepted sockets
+ /// inherit it from the listener.
+ fn small_buffer_listener() -> TcpListener {
+ let s = tokio::net::TcpSocket::new_v4().unwrap();
+ s.set_recv_buffer_size(64 * 1024).unwrap();
+ s.bind("127.0.0.1:0".parse().unwrap()).unwrap();
+ s.listen(1).unwrap()
+ }
+
+ /// What the fake phone saw: the first byte of every frame it opened, and
+ /// whether it then hit one it could not.
+ struct Seen {
+ markers: Vec,
+ failed: bool,
+ }
+
+ /// The phone's side, reduced to what matters here: answer IK, wait for
+ /// `go`, then open every frame in the order it arrives — which is all a
+ /// Noise transport can do, since its nonces are implicit.
+ async fn fake_phone(
+ listener: TcpListener,
+ phone_priv: [u8; 32],
+ prs: [u8; 32],
+ go: oneshot::Receiver<()>,
+ ) -> Seen {
+ let (mut s, _) = listener.accept().await.unwrap();
+ let mut hs = snow::Builder::new(NOISE_IK.parse().unwrap())
+ .local_private_key(&phone_priv)
+ .unwrap()
+ .prologue(&prologue_with_prs(&prs))
+ .unwrap()
+ .build_responder()
+ .unwrap();
+ let mut buf = vec![0u8; 1024];
+ let msg1 = read_frame_capped(&mut s, 1024).await.unwrap();
+ hs.read_message(&msg1.payload, &mut buf).unwrap();
+ let n = hs.write_message(&[], &mut buf).unwrap();
+ write_frame(&mut s, &Frame::new(ty::RECONNECT_HANDSHAKE, 0x02, buf[..n].to_vec()))
+ .await
+ .unwrap();
+ let mut t = hs.into_transport_mode().unwrap();
+ let _ = go.await;
+ let mut seen = Seen {
+ markers: Vec::new(),
+ failed: false,
+ };
+ let mut plain = vec![0u8; MAX_FRAME_PAYLOAD];
+ while let Ok(f) = read_frame_capped(&mut s, MAX_FRAME_PAYLOAD).await {
+ match t.read_message(&f.payload, &mut plain) {
+ Ok(n) if n > 0 => seen.markers.push(plain[0]),
+ Ok(_) => {}
+ Err(_) => {
+ seen.failed = true;
+ break;
+ }
+ }
+ }
+ seen
+ }
+
+ /// A send abandoned while queued must not cost the session its nonce sync.
+ ///
+ /// Regression: the writer sealed under the transport lock, then awaited
+ /// the write lock. `Vfs::read_range` drops its other in-flight reads on the
+ /// first error, EOF or short read, so a read parked behind a large write
+ /// was dropped with its nonce already spent and its frame never sent. The
+ /// phone, which opens frame k with nonce k and does not advance on a
+ /// failure, then refused every later frame for the rest of the session —
+ /// a file manager browsing DCIM/Camera froze on back-to-back 20 s timeouts.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn an_abandoned_send_does_not_desync_the_phone() {
+ const FILL: u8 = 1;
+ const ABANDONED: u8 = 2;
+ const AFTER: u8 = 3;
+ let laptop_priv = [7u8; 32];
+ let phone_priv = [9u8; 32];
+ let prs = [3u8; 32];
+ let listener = small_buffer_listener();
+ let addr = listener.local_addr().unwrap();
+ let (go_tx, go_rx) = oneshot::channel();
+ let phone = tokio::spawn(fake_phone(listener, phone_priv, prs, go_rx));
+
+ let w = open_session(
+ addr,
+ &laptop_priv,
+ &public_from_private(&phone_priv),
+ &prs,
+ 0,
+ Arc::new(|_| {}),
+ Arc::new(|| {}),
+ )
+ .await
+ .unwrap();
+
+ // Jam the write lock: more than the socket buffers hold, with the phone
+ // not reading yet, so one large write sits in `write_all` holding it.
+ let written = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let filler = {
+ let w = w.clone();
+ let written = written.clone();
+ tokio::spawn(async move {
+ // Errors ignored: a phone that lost sync hangs up, and the
+ // assertion below is the one that should say so.
+ for _ in 0..512 {
+ if w(ty::FS_REQ, 0, vec![FILL; 60 * 1024]).await.is_ok() {
+ written.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ }
+ }
+ })
+ };
+ // Wait for the jam to be real rather than assume it after a fixed
+ // delay: the kernel keeps growing the send buffer for a while, which
+ // briefly unblocks the filler — and a send abandoned then would simply
+ // get through. Progress that has stopped for half a second has hit the
+ // buffers' ceiling.
+ let mut last = usize::MAX;
+ loop {
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ let now = written.load(std::sync::atomic::Ordering::Relaxed);
+ if now == last {
+ break;
+ }
+ last = now;
+ }
+
+ // A request that gives up while queued behind it.
+ let gave_up = tokio::time::timeout(
+ Duration::from_millis(100),
+ w(ty::FS_REQ, 0, vec![ABANDONED; 16]),
+ )
+ .await;
+ assert!(gave_up.is_err(), "the write lock was not jammed; the test proves nothing");
+
+ let _ = go_tx.send(());
+ filler.await.unwrap();
+ let _ = w(ty::FS_REQ, 0, vec![AFTER; 16]).await;
+ drop(w);
+
+ let seen = tokio::time::timeout(Duration::from_secs(30), phone)
+ .await
+ .expect("fake phone hung")
+ .unwrap();
+ assert!(
+ !seen.failed,
+ "the phone lost nonce sync after {} frames",
+ seen.markers.len()
+ );
+ assert_eq!(seen.markers.last(), Some(&AFTER), "the last frame never arrived");
+ assert_eq!(seen.markers.iter().filter(|&&m| m == FILL).count(), 512);
+ }
+}
diff --git a/linux/daemon/src/core/lan/tcp_client.rs b/linux/daemon/src/core/lan/tcp_client.rs
index 2dbdd96..22fd122 100644
--- a/linux/daemon/src/core/lan/tcp_client.rs
+++ b/linux/daemon/src/core/lan/tcp_client.rs
@@ -68,6 +68,14 @@ pub struct LanReconnectOutcome {
/// ours to remove, while deleting the phone's own original would need a
/// system consent dialog there for every file.
pub deleted: Vec,
+ /// Clipboard text the phone copied but could not deliver over BLE, from
+ /// the done frame's `clipboard` object.
+ ///
+ /// Phone→laptop clipboard text has no transport but the BLE notify, so a
+ /// laptop whose GATT link never connects silently loses every copy while
+ /// this exchange succeeds every few seconds. Carried here so the link that
+ /// works is the one that delivers, exactly as for [`offers`].
+ pub clipboard: Option,
}
/// Pull the done frame's `offers` array out as real offers.
@@ -103,6 +111,45 @@ fn parse_deleted(json: &[u8]) -> Vec {
arr.iter().filter_map(|e| e.as_str().map(|s| s.to_string())).collect()
}
+/// Pull the done frame's `clipboard` object out as text the phone copied.
+///
+/// The phone's clipboard text is normally a BLE notify. When the GATT link is
+/// down that notify fails and the text is gone, so the phone hands it to this
+/// exchange instead — the same escape hatch `offers` uses, for the one payload
+/// that had no fallback at all.
+fn parse_clipboard(json: &[u8]) -> Option {
+ let v: serde_json::Value = serde_json::from_slice(json).ok()?;
+ let clip = v.get("clipboard")?;
+ let parsed: crate::core::clipboard_mirror::ClipboardMirror =
+ serde_json::from_value(clip.clone()).ok()?;
+ if parsed.text.is_empty() {
+ return None;
+ }
+ Some(parsed)
+}
+
+/// The done frame with any clipboard body replaced by its length.
+///
+/// The frame is logged verbatim, and clipboard content must never reach the
+/// log (the BLE path logs `chars = …` for exactly this reason). Everything
+/// else in the frame is dataset names and tokens, which are worth keeping.
+fn redact_done_frame(json: &[u8]) -> String {
+ let Ok(mut v) = serde_json::from_slice::(json) else {
+ return String::from_utf8_lossy(json).into_owned();
+ };
+ if let Some(obj) = v.as_object_mut() {
+ if let Some(clip) = obj.get_mut("clipboard") {
+ let chars = clip
+ .get("text")
+ .and_then(|t| t.as_str())
+ .map(|t| t.chars().count())
+ .unwrap_or(0);
+ *clip = serde_json::json!({ "text": format!("<{chars} chars>") });
+ }
+ }
+ v.to_string()
+}
+
/// The bulk-sync done frame's per-dataset outcome map.
#[derive(Debug, Clone, Default)]
pub struct BulkStatus(std::collections::HashMap);
@@ -375,6 +422,7 @@ pub async fn run_lan_reconnect(
let mut bulk_status: Option = None;
let mut offers: Vec = Vec::new();
let mut deleted: Vec = Vec::new();
+ let mut clipboard: Option = None;
if let (Some(req), Some(_)) = (bulk_request, peer_state.as_ref()) {
match exchange_bulk(&mut stream, &mut transport, req, wait_per_step).await {
Ok(ex) => {
@@ -382,6 +430,7 @@ pub async fn run_lan_reconnect(
bulk_status = ex.status;
offers = ex.offers;
deleted = ex.deleted;
+ clipboard = ex.clipboard;
}
Err(e) => tracing::warn!("bulk-sync exchange failed: {e}"),
}
@@ -412,6 +461,7 @@ pub async fn run_lan_reconnect(
bulk_status,
offers,
deleted,
+ clipboard,
})
}
@@ -533,6 +583,8 @@ struct BulkExchange {
offers: Vec,
/// Capture tokens whose originals are gone from the phone.
deleted: Vec,
+ /// Clipboard text the phone could not push over BLE, if any.
+ clipboard: Option,
}
/// Send the bulk-sync request and collect the chunked dataset frames the
@@ -557,6 +609,7 @@ async fn exchange_bulk(
let mut out: Vec<(u8, Vec)> = Vec::new();
let mut status: Option = None;
let mut offers: Vec = Vec::new();
+ let mut clipboard: Option = None;
let mut deleted: Vec = Vec::new();
let mut listing = crate::core::phone_files::ListingAssembler::default();
let mut contacts = crate::core::contacts::ContactsAssembler::default();
@@ -599,7 +652,7 @@ async fn exchange_bulk(
pt.truncate(n);
match frame.ty {
ty::BULK_SYNC if frame.sub == 0x02 => {
- info!("← bulk-sync done: {}", String::from_utf8_lossy(&pt));
+ info!("← bulk-sync done: {}", redact_done_frame(&pt));
status = BulkStatus::parse(&pt);
if status.is_none() {
tracing::warn!("bulk-sync: done frame is not a JSON object; no status");
@@ -612,6 +665,10 @@ async fn exchange_bulk(
if !deleted.is_empty() {
info!("← {} capture(s) deleted on the phone", deleted.len());
}
+ clipboard = parse_clipboard(&pt);
+ if let Some(c) = clipboard.as_ref() {
+ info!(chars = c.text.chars().count(), "← LAN clipboard sync");
+ }
break;
}
ty::PHONE_FILES => {
@@ -743,13 +800,13 @@ async fn exchange_bulk(
}
}
}
- Ok(BulkExchange { datasets: out, status, offers, deleted })
+ Ok(BulkExchange { datasets: out, status, offers, deleted, clipboard })
}
#[cfg(test)]
mod tests {
- use super::{parse_deleted, parse_offers, BulkStatus};
+ use super::{parse_clipboard, parse_deleted, parse_offers, redact_done_frame, BulkStatus};
/// The done frame carries offers beside the per-dataset outcomes, and the
/// status map must not choke on the array sitting next to its strings.
@@ -767,6 +824,43 @@ mod tests {
assert_eq!(s.get("contacts"), Some("match"));
}
+ /// Clipboard text rides the done frame beside everything else, and the
+ /// three readers must each ignore the others. This is the phone's only
+ /// way to deliver a copy when the BLE link never connects.
+ #[test]
+ fn clipboard_rides_the_done_frame_beside_the_rest() {
+ let body = br#"{"contacts":"match","deleted":["x"],
+ "offers":[{"token":"t","name":"a.jpg","bytes":1,"mime":"image/jpeg","kind":"photo"}],
+ "clipboard":{"text":"hello laptop","ts":1700000000000}}"#;
+ let clip = parse_clipboard(body).expect("clipboard parsed");
+ assert_eq!(clip.text, "hello laptop");
+ // The neighbours still read their own fields.
+ assert_eq!(BulkStatus::parse(body).unwrap().get("contacts"), Some("match"));
+ assert_eq!(parse_offers(body).len(), 1);
+ assert_eq!(parse_deleted(body), vec!["x".to_string()]);
+ }
+
+ /// A frame with no clipboard, or an empty one, yields nothing — an empty
+ /// string must not clear the laptop's clipboard.
+ #[test]
+ fn absent_or_empty_clipboard_is_not_delivered() {
+ assert!(parse_clipboard(br#"{"contacts":"match"}"#).is_none());
+ assert!(parse_clipboard(br#"{"clipboard":{"text":"","ts":1}}"#).is_none());
+ assert!(parse_clipboard(br#"not json"#).is_none());
+ }
+
+ /// The done frame is logged verbatim, so the clipboard body must be
+ /// replaced by its length before it ever reaches the log.
+ #[test]
+ fn done_frame_log_never_carries_clipboard_text() {
+ let body = br#"{"contacts":"match","clipboard":{"text":"hunter2 is secret","ts":1}}"#;
+ let logged = redact_done_frame(body);
+ assert!(!logged.contains("hunter2"), "clipboard text leaked into the log: {logged}");
+ assert!(logged.contains("17 chars"), "length should survive: {logged}");
+ // Everything else is still there to read.
+ assert!(logged.contains("contacts"));
+ }
+
/// Deletions ride the same frame as the offers and the status map, and
/// each reader must ignore the other two.
#[test]
diff --git a/linux/daemon/src/core/mirror_session.rs b/linux/daemon/src/core/mirror_session.rs
index 504e50b..6516de1 100644
--- a/linux/daemon/src/core/mirror_session.rs
+++ b/linux/daemon/src/core/mirror_session.rs
@@ -353,6 +353,11 @@ async fn seal_and_write_half(
plain: &[u8],
) -> Result<(), String> {
let mut out = vec![0u8; plain.len() + 16];
+ // Write half locked BEFORE sealing: with implicit Noise nonces, a
+ // concurrent caller sealing between our seal and our write would put its
+ // later nonce on the wire first and desync the phone for the rest of the
+ // session. See `fs_lan::open_session`.
+ let mut w = writer_half.lock().await;
let n = {
let mut t = transport.lock().await;
t.write_message(plain, &mut out)
@@ -360,7 +365,6 @@ async fn seal_and_write_half(
};
let frame = Frame::new(ty, sub, out[..n].to_vec());
let bytes = frame.encode();
- let mut w = writer_half.lock().await;
w.write_all(&bytes).await.map_err(|e| format!("tcp write: {e}"))?;
w.flush().await.map_err(|e| format!("tcp flush: {e}"))?;
Ok(())
diff --git a/linux/ui-tauri/src-tauri/src/ble.rs b/linux/ui-tauri/src-tauri/src/ble.rs
index ca9b3de..8e1284d 100644
--- a/linux/ui-tauri/src-tauri/src/ble.rs
+++ b/linux/ui-tauri/src-tauri/src/ble.rs
@@ -447,6 +447,75 @@ pub(crate) async fn find_trusted_presence_peer(
res
}
+/// Power the adapter off and back on, to clear a wedged discovery state.
+///
+/// BlueZ can latch `Discovering = true` with no client behind it. In
+/// `stop_discovery_complete()` a failed `MGMT_OP_STOP_DISCOVERY` removes the
+/// client from `discovery_list` but returns before clearing `discovering` /
+/// `discovery_enable`, so afterwards every `StopDiscovery` is refused with "no
+/// discovery started" and every `StartDiscovery` takes the "queue up a stop"
+/// branch — sent with a NULL callback, so its failure is never seen. The
+/// adapter then reports discovering forever and no scan reaches the kernel,
+/// which starves every connect (bluez/bluez#807, open as of 5.87).
+///
+/// Nothing else clears it. `StopDiscovery` cannot reach the clearing code,
+/// `discovering_callback()` only resyncs on a kernel transition that will not
+/// happen by itself, and the mgmt-level `stop-find` needs CAP_NET_ADMIN, which
+/// a desktop app does not have. The one unconditional reset is in BlueZ's
+/// `adapter_stop()` — the power-down path — and `Powered` is a plain D-Bus
+/// property we can set unprivileged.
+///
+/// Destructive by nature: powering the adapter down drops every link it holds,
+/// the user's audio included. That is exactly why the BLE loop will not do this
+/// on its own and it is offered as a button instead.
+#[tauri::command]
+pub(crate) async fn reset_bluetooth_adapter() -> Result<(), String> {
+ let session = bluer::Session::new()
+ .await
+ .map_err(|e| format!("bluetooth service unavailable: {e}"))?;
+ let adapter = session
+ .default_adapter()
+ .await
+ .map_err(|e| format!("no bluetooth adapter: {e}"))?;
+
+ adapter
+ .set_powered(false)
+ .await
+ .map_err(|e| format!("could not power the adapter down: {e}"))?;
+ // BlueZ tears the adapter down asynchronously; powering back up too early
+ // races `adapter_stop()` and can leave the state we came here to clear.
+ tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
+ adapter
+ .set_powered(true)
+ .await
+ .map_err(|e| format!("could not power the adapter back up: {e}"))?;
+
+ // The link is gone with the adapter; say so rather than let the UI keep
+ // showing a session that died under it.
+ note_link_up(false);
+ tracing::info!("bluetooth adapter reset (power off/on) to clear wedged discovery");
+ Ok(())
+}
+
+/// Whether a BLE audio-signal session is live right now.
+///
+/// The writers map is the real source of truth, but it lives inside the BLE
+/// loop and is reached only by the tasks handed a clone. The peer-state DTO is
+/// built far from there and needs the same answer, so this mirrors it as a
+/// plain flag — the shape `ble_portable::link_is_up` already uses on Windows,
+/// so one accessor can serve both platforms.
+static LINK_UP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+
+/// True while the laptop holds a BLE session to a phone.
+pub(crate) fn link_is_up() -> bool {
+ LINK_UP.load(std::sync::atomic::Ordering::Relaxed)
+}
+
+/// Record link up/down alongside the writers-map insert/remove.
+pub(crate) fn note_link_up(up: bool) {
+ LINK_UP.store(up, std::sync::atomic::Ordering::Relaxed);
+}
+
/// Set when BlueZ rejects advertisement-monitor registration (old daemon /
/// controller without passive-scan support) so we stop re-trying it and log
/// the downgrade exactly once.
@@ -1184,6 +1253,7 @@ pub(crate) async fn run_ble_persistent_loop(
let mut m = ble_audio_writers.lock().await;
m.insert(peer.peer_static_pub, writer_fn);
}
+ note_link_up(true);
// Wake the proximity watcher NOW — link-up is its eager-unlock
// trigger; waiting out its 2s sampling tick is wasted unlock time.
crate::proximity::nudge().notify_one();
@@ -1523,6 +1593,9 @@ pub(crate) async fn run_ble_persistent_loop(
{
let mut m = ble_audio_writers.lock().await;
m.remove(&peer.peer_static_pub);
+ // Only dark once NOTHING is linked: a two-phone laptop tearing one
+ // session down still has BLE.
+ note_link_up(!m.is_empty());
}
// Drop the session device's BlueZ entry: its cached advertisement
// (token valid for up to ±2 buckets ≈ 3 min) would otherwise
diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs
index 2d0bf61..419dd1d 100644
--- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs
+++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs
@@ -344,6 +344,9 @@ pub(crate) fn spawn_clipboard_sync(
// Phone → laptop: a received CLIPBOARD frame → set our system clipboard
// + add to history (so phone copies show up in Super+V).
let (recv_tx, mut recv_rx) = tokio::sync::mpsc::unbounded_channel::();
+ // Publish it for non-BLE transports (the LAN heartbeat's done-frame
+ // fallback), which have no other way to reach this consumer.
+ let _ = CLIPBOARD_RECV_SINK.set(recv_tx.clone());
{
let app = app.clone();
tokio::spawn(async move {
@@ -793,6 +796,25 @@ pub(crate) fn submit_offer(offer: Offer) -> bool {
OFFER_SINK.get().map(|tx| tx.send(offer).is_ok()).unwrap_or(false)
}
+/// The incoming-clipboard consumer's sender, so a transport that isn't BLE can
+/// hand it text.
+///
+/// Same reasoning as [`OFFER_SINK`]: the BLE listener gets its own clone at
+/// wiring time, and the LAN heartbeat runs far from that but must reach the
+/// same consumer — so the loop guard, the tidy pass and the clipboard history
+/// all behave identically no matter which link carried the text.
+static CLIPBOARD_RECV_SINK: OnceLock<
+ tokio::sync::mpsc::UnboundedSender,
+> = OnceLock::new();
+
+/// Hand received clipboard text to the consumer. `false` when there is no
+/// consumer yet (before wiring) or it has stopped.
+pub(crate) fn submit_clipboard(
+ clip: vortex_l3_daemon::core::clipboard_mirror::ClipboardMirror,
+) -> bool {
+ CLIPBOARD_RECV_SINK.get().map(|tx| tx.send(clip).is_ok()).unwrap_or(false)
+}
+
pub(crate) fn spawn_image_offer_consumer() -> tokio::sync::mpsc::UnboundedSender {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::();
let _ = OFFER_SINK.set(tx.clone());
diff --git a/linux/ui-tauri/src-tauri/src/fs_fuse.rs b/linux/ui-tauri/src-tauri/src/fs_fuse.rs
index 522cb52..f7e17bd 100644
--- a/linux/ui-tauri/src-tauri/src/fs_fuse.rs
+++ b/linux/ui-tauri/src-tauri/src/fs_fuse.rs
@@ -38,9 +38,9 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime};
use fuser::{
- Errno, FileAttr, FileType, FopenFlags, Generation, INodeNo, KernelConfig, MountOption,
- OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry,
- ReplyOpen, ReplyStatfs, ReplyXattr, Request,
+ Errno, FileAttr, FileType, FopenFlags, Generation, INodeNo, InitFlags, KernelConfig,
+ MountOption, OpenAccMode, OpenFlags, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty,
+ ReplyEntry, ReplyOpen, ReplyStatfs, ReplyXattr, Request,
};
use vortex_l3_daemon::core::fs_proto::{self as p, code};
@@ -211,6 +211,17 @@ impl fuser::Filesystem for PhoneFs {
// Background requests are how many of those may be outstanding. Ours
// are answered off-thread, so a deeper queue costs nothing here.
let _ = config.set_max_background(MAX_INFLIGHT as u16 * 2);
+ // What makes FOPEN_KEEP_CACHE (see `open`) safe: cached pages are
+ // invalidated when the attributes we report change, so a photo edited
+ // on the phone is not served stale for longer than ATTR_TTL. Every
+ // kernel since 2.6.x has it; if one somehow does not, keeping the cache
+ // is still right for a read-only mount of mostly immutable media.
+ if let Err(missing) = config.add_capabilities(InitFlags::FUSE_AUTO_INVAL_DATA) {
+ tracing::warn!(
+ ?missing,
+ "fs-mount: kernel lacks auto-invalidation; cache may lag edits"
+ );
+ }
tracing::info!(readahead, "fs-mount: kernel session up");
Ok(())
}
@@ -315,9 +326,15 @@ impl fuser::Filesystem for PhoneFs {
return reply.error(Errno::ENOENT);
};
match vfs.open(&addr).await {
- // No flags: keeping the page cache is what lets the kernel serve
- // a re-read without us, and read ahead of the reader.
- Ok(fh) => reply.opened(fuser::FileHandle(fh), FopenFlags::empty()),
+ // KEEP_CACHE, or the kernel throws away this file's cached
+ // pages on every open — the opposite of what the old comment
+ // here assumed. Without it a photo the thumbnailer just read
+ // came over Wi-Fi again when the image viewer opened it, while
+ // the thumbnailers were still saturating the link. Staleness
+ // is covered by FUSE_AUTO_INVAL_DATA (see `init`): the kernel
+ // drops the pages itself when a getattr shows a new size or
+ // mtime.
+ Ok(fh) => reply.opened(fuser::FileHandle(fh), FopenFlags::FOPEN_KEEP_CACHE),
Err(c) => reply.error(errno_of(c)),
}
});
diff --git a/linux/ui-tauri/src-tauri/src/fs_lan.rs b/linux/ui-tauri/src-tauri/src/fs_lan.rs
index 6b8281a..ac697f2 100644
--- a/linux/ui-tauri/src-tauri/src/fs_lan.rs
+++ b/linux/ui-tauri/src-tauri/src/fs_lan.rs
@@ -47,6 +47,11 @@ struct Session {
/// that issues fifty reads over a dead network produces one notification
/// rather than fifty.
warned: bool,
+ /// Bumped per session opened, so a session's `on_closed` can tell whether
+ /// it is still the current one. A session retired by [`close`] keeps its
+ /// read loop until the peer hangs up, and that late callback must not
+ /// clear the session that replaced it.
+ generation: u64,
}
fn session() -> &'static tokio::sync::Mutex {
@@ -124,11 +129,14 @@ pub(crate) async fn writer() -> Option {
payload: f.payload,
});
});
- let on_closed = Arc::new(|| {
- tokio::spawn(async {
+ let generation = s.generation.wrapping_add(1);
+ let on_closed = Arc::new(move || {
+ tokio::spawn(async move {
let mut s = session().lock().await;
- s.writer = None;
- s.last_used = None;
+ if s.generation == generation {
+ s.writer = None;
+ s.last_used = None;
+ }
});
});
@@ -144,6 +152,7 @@ pub(crate) async fn writer() -> Option {
.await
{
Ok(w) => {
+ s.generation = generation;
s.writer = Some(w.clone());
s.last_used = Some(Instant::now());
s.last_failed = None;
diff --git a/linux/ui-tauri/src-tauri/src/fs_link.rs b/linux/ui-tauri/src-tauri/src/fs_link.rs
index 3d93aff..60d4cc4 100644
--- a/linux/ui-tauri/src-tauri/src/fs_link.rs
+++ b/linux/ui-tauri/src-tauri/src/fs_link.rs
@@ -269,6 +269,13 @@ async fn round_trip(op: u8, id: u32, payload: Vec) -> Result {
g.remove(&id);
}
tracing::warn!(id, "fs: request timed out");
+ // Do not keep using a LAN session that just ate a request. The
+ // phone drops a frame it cannot open without saying so, and a
+ // Noise session that has lost nonce sync never recovers — every
+ // later request would also wait out the full timeout, one after
+ // another, for as long as the file manager keeps asking. Dropping
+ // it makes the next request reopen (or fall back to BLE) instead.
+ crate::fs_lan::close().await;
Err(code::IO)
}
}
@@ -450,7 +457,7 @@ pub(crate) async fn read_all(
let Some((at, res)) = pending.next().await else {
break Ok(offset);
};
- let (bytes, eof) = match res {
+ let (bytes, mut eof) = match res {
Ok(v) => v,
Err(c) => break Err(c),
};
@@ -464,6 +471,38 @@ pub(crate) async fn read_all(
tracing::warn!(at, "fs: read stalled without EOF");
break Err(code::IO);
}
+ // A short chunk is not the end of the file — the phone answers
+ // with one `pread`, which may return less than asked. The next
+ // chunk is already in flight at `at + MAX_READ_LEN`, so carrying on
+ // would leave a hole of zeros in the copy. Fetch the gap first.
+ let chunk_end = (at + p::MAX_READ_LEN as u64).min(size);
+ let mut stalled = false;
+ while !eof && offset < chunk_end {
+ let want = (chunk_end - offset) as u32;
+ match read(handle, offset, want).await {
+ Ok((more, _)) if more.is_empty() => {
+ stalled = true;
+ break;
+ }
+ Ok((more, e)) => {
+ if let Err(err) = sink(offset, &more) {
+ tracing::warn!("fs: sink failed at offset {offset}: {err}");
+ stalled = true;
+ break;
+ }
+ offset += more.len() as u64;
+ eof = e;
+ }
+ Err(_) => {
+ stalled = true;
+ break;
+ }
+ }
+ }
+ if stalled {
+ tracing::warn!(offset, "fs: could not complete a short read");
+ break Err(code::IO);
+ }
if eof {
break Ok(offset);
}
diff --git a/linux/ui-tauri/src-tauri/src/fs_vfs.rs b/linux/ui-tauri/src-tauri/src/fs_vfs.rs
index baa5e19..f8ccc30 100644
--- a/linux/ui-tauri/src-tauri/src/fs_vfs.rs
+++ b/linux/ui-tauri/src-tauri/src/fs_vfs.rs
@@ -141,16 +141,46 @@ impl FsRemote for LinkRemote {
// The cache
// ---------------------------------------------------------------------------
-/// A file a caller has open, keyed by the handle we handed back.
+/// A file a caller has open, keyed by the handle we handed back. Ours is a
+/// separate number from the peer's, so a peer handle of 0 (or a reused one)
+/// cannot collide with "no handle" — and so several of ours can share one of
+/// the peer's.
struct OpenFile {
- /// The peer's handle. Ours is a separate number so a peer handle of 0 (or a
- /// reused one) cannot collide with "no handle".
- remote: u64,
- /// Size as of open. Reads are clamped to it so we never ask the phone for a
- /// range past the end just because the caller rounded up to a page.
- size: u64,
+ addr: String,
}
+/// The peer's handle on one file, shared by every local handle open on it.
+///
+/// Opening is where a file browser's per-file cost went. Gwenview, asked to
+/// show one photo, reads the header of every file in the folder on its GUI
+/// thread and opens each one three times in a row to do it: measured at 450
+/// files in 35 s, ~53 ms a file, nearly all of it OPEN round trips. So:
+///
+/// * the peer is not asked at `open()` but at the first read — an open that is
+/// never read costs nothing;
+/// * overlapping opens of one file share one peer handle (the peer reads with
+/// `pread`, so concurrent use of a handle is safe);
+/// * the last close leaves it open for [`REMOTE_LINGER`], so open-close-open in
+/// quick succession reuses it too.
+struct SharedRemote {
+ /// Local handles currently open on this file.
+ users: usize,
+ /// `(peer handle, size)`, filled by the first read. Left empty by a failed
+ /// open so the next read retries rather than inheriting the failure.
+ opened: Arc>,
+ /// When `users` last fell to zero; `None` while in use.
+ idle_since: Option,
+}
+
+/// How long an unused peer handle is kept for a quick reopen. Long enough to
+/// cover a program that reopens a file it just closed, short enough that a
+/// file changed on the phone is not read through an old descriptor for long.
+const REMOTE_LINGER: Duration = Duration::from_secs(2);
+
+/// Idle peer handles kept at most. The phone refuses OPEN past 64 handles, and
+/// a lingering handle should never be the reason a real open fails.
+const MAX_IDLE_REMOTES: usize = 8;
+
/// A cached value and when it was cached, so a reader can check it against a
/// TTL without a second map.
type Cached = (Instant, T);
@@ -164,8 +194,14 @@ pub(crate) struct Vfs {
/// Attributes by address, seeded from listings.
attrs: Mutex>>,
files: Mutex>,
+ /// Peer handles by address; see [`SharedRemote`].
+ remotes: Mutex>,
next_fh: AtomicU64,
gate: tokio::sync::Semaphore,
+ /// One lock per directory being fetched, so concurrent lookups in the same
+ /// folder share a single listing instead of each pulling it whole. See
+ /// [`Vfs::listing`].
+ fetching: Mutex>>>,
}
impl Vfs {
@@ -175,8 +211,10 @@ impl Vfs {
dirs: Mutex::new(HashMap::new()),
attrs: Mutex::new(HashMap::new()),
files: Mutex::new(HashMap::new()),
+ remotes: Mutex::new(HashMap::new()),
next_fh: AtomicU64::new(1),
gate: tokio::sync::Semaphore::new(MAX_INFLIGHT),
+ fetching: Mutex::new(HashMap::new()),
}
}
@@ -223,6 +261,34 @@ impl Vfs {
if let Some(entries) = self.cached_dir(addr) {
return Ok(entries);
}
+ // Single flight. A file manager generating thumbnails looks up every
+ // file in a folder at once, and once the listing has expired each of
+ // those lookups used to fetch the whole folder for itself — 485 entries
+ // of DCIM/Camera, several pages each, a dozen times over, all competing
+ // for the same [`MAX_INFLIGHT`] slots. Whoever gets here first fetches;
+ // the rest wait on the same lock and find the result in the cache.
+ let flight = self
+ .fetching
+ .lock()
+ .map(|mut g| g.entry(addr.to_string()).or_default().clone())
+ .unwrap_or_default();
+ let _turn = flight.lock().await;
+ if let Some(entries) = self.cached_dir(addr) {
+ return Ok(entries);
+ }
+ let result = self.fetch_listing(addr).await;
+ // Retire the lock once nobody else holds it; a waiter still queued on
+ // it keeps its own clone and finds the cache filled.
+ if let Ok(mut g) = self.fetching.lock() {
+ if Arc::strong_count(&flight) <= 2 {
+ g.remove(addr);
+ }
+ }
+ result
+ }
+
+ /// The listing round trips themselves, under [`Vfs::listing`]'s lock.
+ async fn fetch_listing(&self, addr: &str) -> Result>, i32> {
let mut all: Vec = Vec::new();
let mut cursor = 0u32;
loop {
@@ -270,15 +336,30 @@ impl Vfs {
/// wins, so a peer that really does hold `README` and `readme` resolves
/// each to itself.
pub(crate) async fn lookup_child(&self, parent: &str, name: &str) -> Result {
- let entries = self.listing(parent).await?;
- if let Some(e) = entries.iter().find(|e| e.name == name) {
- return Ok(e.clone());
+ // An expired listing still says which address a name had. Checking that
+ // one address with a STAT is a single small round trip; re-listing the
+ // parent is the whole folder — measured at ~3 s for DCIM/Camera's 485
+ // entries, paid by every lookup after the listing is 5 s old (an image
+ // viewer opening a photo, a thumbnailer coming back to a file). Only a
+ // name the old listing did not have, or one whose address is gone,
+ // needs the full fetch.
+ if self.cached_dir(parent).is_none() {
+ let known = self
+ .dirs
+ .lock()
+ .ok()
+ .and_then(|g| g.get(parent).and_then(|(_, v)| find_child(v, name)));
+ if let Some(known) = known {
+ match self.entry_of(&known.path).await {
+ Ok(fresh) => return Ok(fresh),
+ // Deleted or renamed since: let the listing decide.
+ Err(c) if c == code::NOENT => {}
+ Err(c) => return Err(c),
+ }
+ }
}
- entries
- .iter()
- .find(|e| e.name.eq_ignore_ascii_case(name))
- .cloned()
- .ok_or(code::NOENT)
+ let entries = self.listing(parent).await?;
+ find_child(&entries, name).ok_or(code::NOENT)
}
/// One address's attributes.
@@ -329,28 +410,76 @@ impl Vfs {
Ok(entry)
}
- /// Open a file by address. The handle returned is ours, not the peer's.
+ /// Open a file. Does not ask the peer — see [`SharedRemote`] — so a file
+ /// removed on the phone since it was listed fails on its first read rather
+ /// than here.
pub(crate) async fn open(&self, addr: &str) -> Result {
- let (remote, size) = self.gated(self.remote.open(addr.to_string())).await?;
let fh = self.next_fh.fetch_add(1, Ordering::Relaxed);
+ if let Ok(mut g) = self.remotes.lock() {
+ let shared = g.entry(addr.to_string()).or_insert_with(|| SharedRemote {
+ users: 0,
+ opened: Default::default(),
+ idle_since: None,
+ });
+ shared.users += 1;
+ shared.idle_since = None;
+ }
if let Ok(mut g) = self.files.lock() {
- g.insert(fh, OpenFile { remote, size });
+ g.insert(
+ fh,
+ OpenFile {
+ addr: addr.to_string(),
+ },
+ );
}
Ok(fh)
}
- /// Size of an open file, as of when it was opened.
- #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
- pub(crate) fn size_of(&self, fh: u64) -> Option {
- self.files.lock().ok().and_then(|g| g.get(&fh).map(|f| f.size))
+ /// The peer's `(handle, size)` for an open file, opening it on the peer if
+ /// this is the first read through any handle on it.
+ async fn remote_of(&self, fh: u64) -> Result<(u64, u64), i32> {
+ let addr = self
+ .files
+ .lock()
+ .ok()
+ .and_then(|g| g.get(&fh).map(|f| f.addr.clone()))
+ .ok_or(code::BADF)?;
+ let opened = self
+ .remotes
+ .lock()
+ .ok()
+ .and_then(|g| g.get(&addr).map(|s| s.opened.clone()))
+ .ok_or(code::BADF)?;
+ opened
+ .get_or_try_init(|| self.gated(self.remote.open(addr.clone())))
+ .await
+ .copied()
}
/// Release a handle. Unknown handles are ignored — a double close is the
/// caller's business, not an error worth propagating.
+ ///
+ /// The peer handle outlives it briefly (see [`SharedRemote`]); this is also
+ /// where lingering ones that have waited long enough are closed for real.
pub(crate) async fn close(&self, fh: u64) {
- let f = self.files.lock().ok().and_then(|mut g| g.remove(&fh));
- if let Some(f) = f {
- self.gated(self.remote.close(f.remote)).await;
+ let Some(f) = self.files.lock().ok().and_then(|mut g| g.remove(&fh)) else {
+ return;
+ };
+ let retired = self
+ .remotes
+ .lock()
+ .map(|mut g| {
+ if let Some(shared) = g.get_mut(&f.addr) {
+ shared.users = shared.users.saturating_sub(1);
+ if shared.users == 0 {
+ shared.idle_since = Some(Instant::now());
+ }
+ }
+ take_retired(&mut g)
+ })
+ .unwrap_or_default();
+ for handle in retired {
+ self.gated(self.remote.close(handle)).await;
}
}
@@ -368,12 +497,9 @@ impl Vfs {
pub(crate) async fn read_range(&self, fh: u64, offset: u64, size: u32) -> Result, i32> {
use futures::stream::{FuturesOrdered, StreamExt};
- let (remote, file_size) = self
- .files
- .lock()
- .ok()
- .and_then(|g| g.get(&fh).map(|f| (f.remote, f.size)))
- .ok_or(code::BADF)?;
+ // Reads are clamped to the size the peer reported at open, so we never
+ // ask for a range past the end just because the caller rounded up.
+ let (remote, file_size) = self.remote_of(fh).await?;
if offset >= file_size {
return Ok(Vec::new());
}
@@ -389,22 +515,43 @@ impl Vfs {
let at = next;
let len = (end - at).min(p::MAX_READ_LEN as u64) as u32;
pending.push_back(async move {
- (at, self.gated(self.remote.read(remote, at, len)).await)
+ (at, len, self.gated(self.remote.read(remote, at, len)).await)
});
next = at + len as u64;
}
- let Some((at, res)) = pending.next().await else {
+ let Some((at, len, res)) = pending.next().await else {
break;
};
- let (bytes, eof) = res?;
+ let (bytes, mut eof) = res?;
if at != expect {
- // An earlier piece came back short, so this one starts past the
- // end of what we have. Anything further would land at the wrong
- // file offset.
+ // Only reachable after the fill below gave up on a piece, so
+ // this one starts past the end of what we have. Anything further
+ // would land at the wrong file offset.
break;
}
expect = at + bytes.len() as u64;
out.extend_from_slice(&bytes);
+ // A short piece is not the end of the file. The phone answers with
+ // one `pread`, and Android's /sdcard is itself a FUSE mount that
+ // may hand back less than asked. Passing that on would be worse
+ // than slow: FUSE takes a short reply as "the file ends here", so a
+ // photo came out cut off mid-file ("Corrupt JPEG data: premature
+ // end of data segment") — and, with FOPEN_KEEP_CACHE, stayed that
+ // way in the page cache. Ask for the rest of the piece instead.
+ let piece_end = at + len as u64;
+ while !eof && expect < piece_end {
+ let want = (piece_end - expect) as u32;
+ let (more, e) = self.gated(self.remote.read(remote, expect, want)).await?;
+ if more.is_empty() {
+ // No progress and no EOF: the file shrank under us, or the
+ // peer is not making sense. Stop rather than spin.
+ eof = true;
+ break;
+ }
+ expect += more.len() as u64;
+ out.extend_from_slice(&more);
+ eof = e;
+ }
if eof || bytes.is_empty() {
break;
}
@@ -419,6 +566,37 @@ impl Vfs {
/// list of what it shares, not a directory it can stat, and listing the mount
/// point must work regardless. A zero mtime rather than "now" so the root does
/// not appear to change on every remount.
+/// Remove the idle peer handles that are due to close — past
+/// [`REMOTE_LINGER`], or the oldest beyond [`MAX_IDLE_REMOTES`] — and return
+/// the ones the peer actually holds.
+fn take_retired(g: &mut HashMap) -> Vec {
+ let mut idle: Vec<(Instant, String)> = g
+ .iter()
+ .filter_map(|(addr, s)| s.idle_since.map(|t| (t, addr.clone())))
+ .collect();
+ idle.sort();
+ let over_cap = idle.len().saturating_sub(MAX_IDLE_REMOTES);
+ let mut out = Vec::new();
+ for (i, (since, addr)) in idle.into_iter().enumerate() {
+ if i < over_cap || since.elapsed() >= REMOTE_LINGER {
+ if let Some(&(handle, _)) = g.remove(&addr).as_ref().and_then(|s| s.opened.get()) {
+ out.push(handle);
+ }
+ }
+ }
+ out
+}
+
+/// A name within a listing: exact match first, then case-insensitive (see
+/// [`Vfs::lookup_child`] for why both).
+fn find_child(entries: &[p::FsEntry], name: &str) -> Option {
+ entries
+ .iter()
+ .find(|e| e.name == name)
+ .or_else(|| entries.iter().find(|e| e.name.eq_ignore_ascii_case(name)))
+ .cloned()
+}
+
pub(crate) fn root_entry() -> p::FsEntry {
p::FsEntry {
name: "/".to_string(),
@@ -449,6 +627,11 @@ pub(crate) mod tests {
pub(crate) lists: std::sync::atomic::AtomicU32,
pub(crate) stats: std::sync::atomic::AtomicU32,
pub(crate) reads: std::sync::atomic::AtomicU32,
+ pub(crate) opens: std::sync::atomic::AtomicU32,
+ pub(crate) closes: std::sync::atomic::AtomicU32,
+ /// Answer every read with at most a third of what was asked, the way a
+ /// single `pread` on Android's own FUSE-backed storage sometimes does.
+ pub(crate) short_reads: std::sync::atomic::AtomicBool,
}
pub(crate) fn dir(name: &str, path: &str) -> p::FsEntry {
@@ -493,12 +676,20 @@ pub(crate) mod tests {
let mut files = HashMap::new();
files.insert("/sdcard/a.txt".to_string(), b"hello".to_vec());
files.insert("/sdcard/DCIM/big.bin".to_string(), big);
+ // Readable but not listed: enough distinct files to overflow the
+ // idle-handle cap.
+ for i in 0..MAX_IDLE_REMOTES + 3 {
+ files.insert(format!("/sdcard/many/{i}.bin"), vec![i as u8; 8]);
+ }
Self {
dirs,
files,
lists: Default::default(),
stats: Default::default(),
reads: Default::default(),
+ opens: Default::default(),
+ closes: Default::default(),
+ short_reads: Default::default(),
}
}
}
@@ -512,6 +703,9 @@ pub(crate) mod tests {
let me = self.clone();
async move {
me.lists.fetch_add(1, Ordering::Relaxed);
+ // A real round trip suspends; without this, concurrent callers
+ // in a test would never actually overlap.
+ tokio::task::yield_now().await;
let all = me.dirs.get(&path).ok_or(code::NOENT)?;
// One entry per page, so pagination is exercised rather than
// assumed.
@@ -539,6 +733,7 @@ pub(crate) mod tests {
fn open(&self, path: String) -> impl Future