Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
c874043
fix(pairing): drop the BT bond when a peer is forgotten
claude Aug 25, 2026
b560167
feat(cache): namespace phone-specific caches per peer
claude Aug 25, 2026
49227a8
feat(multi-peer): pair a second laptop, and split active from connected
claude Aug 25, 2026
5d94098
feat(multi-peer): "Switch device" on the laptop
claude Aug 25, 2026
d404bf1
feat(multi-peer): phone-side Seeking machinery
claude Aug 26, 2026
c300763
fix(calllog): stop putting SQL LIMIT in the provider sortOrder
claude Aug 26, 2026
cfffe54
feat(multi-peer): PEER_HANDOFF frame — tell a displaced peer it lost …
claude Aug 26, 2026
a88bbad
feat(multi-peer): the other half of PEER_HANDOFF — phone→laptop RELEASE
claude Aug 26, 2026
1a73326
fix(sms): window in Kotlin, not with SQL LIMIT in the sortOrder
claude Aug 26, 2026
a6a9164
docs: communication scheme and multi-peer design
claude Aug 26, 2026
3a2cffa
fix(share): don't crash the app on an oversized file share
claude Aug 26, 2026
dd10c9d
fix(share): queue a multi-file share instead of silently dropping mos…
claude Aug 26, 2026
f661564
fix(ble): advertise when ACL-connected but sessionless
claude Aug 26, 2026
a3176fe
fix(wifi-direct): only switch networks when it actually helps
claude Aug 26, 2026
a39e3c4
fix(files): make an incoming pull idempotent per content token
claude Aug 26, 2026
476cdb1
docs: design for browsing the phone's files from the desktop
claude Aug 26, 2026
5c0b44f
fix(ui): make the phone's laptop card show — and offer — the right de…
claude Aug 27, 2026
5d98168
feat(ui): show every paired laptop, and switch to a named one
claude Aug 27, 2026
b16d49a
build(daemon): declare the tokio features the crate actually uses
X-Ryl669 Aug 21, 2026
2b8c812
feat(platform): a platform seam, and a daemon library that builds for…
X-Ryl669 Aug 21, 2026
e7ccdcf
feat(platform): the seam now serves both platforms end to end
X-Ryl669 Aug 21, 2026
3440ff9
fix(notify): deliver Windows notification actions to every consumer
X-Ryl669 Aug 21, 2026
3b17675
feat(platform): Credential Manager, input capture and autostart on Wi…
X-Ryl669 Aug 21, 2026
f10deff
feat(ui): the Tauri app builds for Windows, with BLE over the platfor…
X-Ryl669 Aug 21, 2026
59d8e47
fix(windows): make the Windows build actually run, pair and receive
X-Ryl669 Aug 23, 2026
90b2fb7
fix(windows): wire up forget, and stop the dead buttons claiming success
X-Ryl669 Aug 27, 2026
c923c04
fix(windows): carry multi-peer through the platform seam
X-Ryl669 Aug 27, 2026
c552729
fix(logging): keep a log file on Linux too, so a bad run leaves evidence
X-Ryl669 Aug 27, 2026
2896b8f
fix(ble): drop a peer's CCCD subscriptions when its link dies
X-Ryl669 Aug 28, 2026
2fbe72f
fix(handoff): open a shared page once, not every 12 seconds forever
claude Aug 28, 2026
c1234e2
feat(fs): a ranged filesystem protocol, symmetric in both directions
claude Aug 28, 2026
e3bb82f
feat(fs): the laptop serves and speaks the filesystem protocol
claude Aug 28, 2026
172d61f
fix(ble): reconnect the peer that answered, not the first one stored
X-Ryl669 Sep 5, 2026
285da99
feat(fs): the phone serves the filesystem protocol
X-Ryl669 Sep 5, 2026
8b5333d
feat(fs): drive the filesystem protocol from the command line
X-Ryl669 Sep 6, 2026
8abed65
feat(fs): "Allow access to any files", behind a setting
X-Ryl669 Sep 6, 2026
d0e7666
fix(ble): stop an oversized notify killing the phone app
X-Ryl669 Sep 6, 2026
50aa13b
fix(logging): don't let a CLI invocation destroy the running app's log
X-Ryl669 Sep 6, 2026
d4064ad
docs(fs): record what step 1 actually did on a device
X-Ryl669 Sep 6, 2026
b5b22c8
feat(fs): prefer Wi-Fi for filesystem traffic, fall back to Bluetooth
X-Ryl669 Sep 6, 2026
45467cc
fix(fs): fall back to BLE without stalling, and say so when it happens
X-Ryl669 Sep 6, 2026
97c4c9d
feat(fs): move file transfer onto ranged reads, and delete the size cap
X-Ryl669 Sep 6, 2026
12f71a5
feat(fs): browse the laptop's files from the phone
X-Ryl669 Sep 6, 2026
a7e7b66
fix(fs): let a filesystem reply come back on either transport
X-Ryl669 Sep 6, 2026
cdf7aa6
fix(fs): keep the file browser's header out from under the status bar
X-Ryl669 Sep 6, 2026
46b91d5
fix(ui): keep the home screen's header out from under the status bar
X-Ryl669 Sep 6, 2026
0f661b6
feat(fs): the phone's browsing rides Wi-Fi too, after the first request
X-Ryl669 Sep 6, 2026
a9e7b9b
perf(fs): pipeline ranged reads, in both clients
X-Ryl669 Sep 6, 2026
fdb8fea
docs(fs): record the pipelining measurements
X-Ryl669 Sep 6, 2026
9d0f7a2
feat(fs): mount the phone's storage over FUSE
X-Ryl669 Sep 7, 2026
61d3b7f
fix(fs): answer the ops the kernel actually asks, and remount over a …
X-Ryl669 Sep 7, 2026
2a311c8
feat(ui): a folder button on the phone's card opens its files
X-Ryl669 Sep 7, 2026
c95e023
style(ui): the files button matches the ring buttons above it
claude Sep 7, 2026
c375206
feat(fs): project the phone's storage on Windows, with ProjFS
claude Sep 11, 2026
0d9fc0e
docs(fs): the Windows cross-build, and the load-time import it exposed
claude Sep 12, 2026
090546d
fix(windows): claim the arbiter on connect, and stop keying caches of…
claude Sep 12, 2026
a8a4748
fix(windows): the heap corruption that killed the first run, and a wh…
claude Sep 12, 2026
be70a1a
fix(multi-peer): connected is not active, on the phone side too
claude Sep 12, 2026
dcd89e0
fix(multi-peer): gate the LAN path on ownership too — that is where t…
claude Sep 12, 2026
0d3687c
fix(windows): let the LAN heartbeat relax when BLE is up
claude Sep 12, 2026
c172c2e
fix(ui): keep the settings header out from under the status bar
claude Sep 12, 2026
8f9875d
fix(windows): give the portable BLE loop a state beat
claude Sep 12, 2026
499970d
feat(ui): a way to pair a second phone without forgetting the first
claude Sep 12, 2026
8fd09d6
feat(ui): mirror Android's paired-devices layout on the laptop
claude Sep 12, 2026
384eca0
fix(ui): a phone, not a plus, on the pair-another tile
claude Sep 12, 2026
6481e2b
fix(rebase): reconcile this branch with upstream's six release snapshots
claude Sep 12, 2026
b14894c
fix(windows): keep the log out of the temp directory
claude Sep 12, 2026
39554b3
fix(projfs): never hold a filesystem callback for twenty seconds
claude Sep 13, 2026
d76a2cb
fix(windows): let the state beat end a session the listener cannot see
claude Sep 13, 2026
6c1c5d7
feat(windows): "Share via Vortex" in Explorer
claude Sep 13, 2026
ee6bc01
perf(ui): ship the logo at the size it is drawn
claude Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<!-- File browsing: serve the whole of shared storage to a paired laptop.
Declared but NOT requested at launch — it is reached only through the
"Allow access to any files" setting, because it is a special access
the user grants on a system screen, and the default remains the SAF
folder picker (design doc §5). Play Store restricts this permission;
Vortex ships via GitHub releases, so that is a labelling concern
rather than a blocker. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- Continuity Camera: use the phone camera as a laptop webcam. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
Expand Down
194 changes: 143 additions & 51 deletions android/app/src/main/java/com/vortex/a3/core/ble/Advertiser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ class Advertiser(private val context: Context) {
// ~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
Expand Down Expand Up @@ -174,87 +180,158 @@ class Advertiser(private val context: Context) {
}

/**
* Start trusted-presence advertising with a rotating token derived
* from [prs] per spec §7.3. The token rotates every
* [rotationWindowSec] seconds so passive observers cannot link
* sightings across windows.
* True while a peer session is live. When it is, the presence loop
* advertises **nothing**: the session itself is the proof of presence, so
* a beacon on top of it is pure battery cost. Wired by VortexStack to the
* GATT server's connection state.
*
* The supplied [scope] owns the rotation job. Cancel the scope (or
* call [stop]) to end advertising.
* This is the biggest saving in the whole state machine — the phone is
* connected most of the time, and it used to beacon 24/7 regardless. It is
* safe for the laptop's proximity auto-lock precisely because that treats
* "authenticated session OR token-validated advertisement" as presence,
* and on a drop [kickRotation] puts us back on air immediately.
*/
fun startTrustedPresence(
prs: ByteArray,
var linkedProvider: (() -> Boolean)? = null

/**
* The PRS of every peer whose token we may advertise, most-recently-used
* first. Returning several enables token multiplexing (below).
*/
var presencePeersProvider: (() -> List<ByteArray>)? = null

/**
* Set while the user is looking for a *different* laptop ("Switch").
* Forces advertising even though a session is live, so the other laptop
* can see us without dropping the one we are on first.
*/
@Volatile
var seeking: Boolean = false

/**
* Presence + seeking loop (spec §7.3, design doc §D1/§D5).
*
* One advertising set, driven through three phases:
*
* * **Active** — a session is live and we are not seeking: advertise
* nothing, and re-check often enough that a missed disconnect callback
* self-heals in seconds rather than a full rotation window.
* * **Seeking / Dark** — no session (or the user pressed Switch):
* advertise `TRUSTED_PRESENCE`. [fastModeProvider] already supplies the
* ladder — LOW_LATENCY while the link was recently lost, BALANCED after
* that. BALANCED is the floor rather than silence on purpose: the
* laptop's proximity confirmation scan is short, and a present-but-
* silent phone would be mistaken for one that walked away.
*
* **Token multiplexing.** The advertisement carries exactly one 8-byte
* token and the ADV_IND is already at the legacy 31-byte ceiling, so N
* 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.
*/
fun startPresenceLoop(
scope: CoroutineScope,
rotationWindowSec: Long = 60L,
/** True while a peer is connected over GATT. See the rotation loop. */
isConnected: () -> Boolean = { false },
onError: (String) -> Unit = {},
) {
require(prs.size == 32) { "PRS must be 32 bytes" }
// Cancel any existing rotation before starting a new one. Stop
// current adv too so we start the new mode cleanly.
presenceJob?.cancel()
stop()
val prsCopy = prs.copyOf()
presenceJob = scope.launch {
// Consecutive start failures. Each bucket retries regardless
// Consecutive start failures. Each round retries regardless
// (restarting an advertiser is cheap and the radio may have just
// come back), but a persistent failure must not stay silent —
// the phone is INVISIBLE over BLE while this fails. Surface it
// once via onError after a few misses, then again only if it
// keeps failing after a recovery.
var consecFails = 0
var wasSilent = false
while (isActive) {
val nowSec = System.currentTimeMillis() / 1000
val bucket = Presence.currentBucket(nowSec, rotationWindowSec)
val token = Presence.deriveToken(prsCopy, bucket)
// Do NOT restart the advertiser while a peer is connected.
//
// Stopping and starting an advertising set makes Android hand
// out a fresh resolvable private address. Doing that every 60 s
// (and again on every characteristic subscribe) meant the
// laptop's cached address was ALWAYS dead by the time it tried
// to reconnect, so its "connect straight to the last address"
// fast path could never once succeed — every reconnect paid a
// full 15 s scan, and six such failures in a row used to make
// the laptop power-cycle its whole Bluetooth adapter.
//
// The rotation exists to stop a passive observer linking our
// advertisements over time. A connected peer is not that
// observer: it already knows exactly who we are, and while the
// link is up nobody is scanning for us. So rotate when it
// matters — between sessions — and hold still while connected.
if (isConnected() && activePayload != null) {
Log.d(TAG, "presence rotation held: peer connected (keeping this RPA)")
val intoBucket = nowSec % rotationWindowSec
withTimeoutOrNull((rotationWindowSec - intoBucket + 5L) * 1000) {
rotationKick.receive()
val linked = linkedProvider?.invoke() == true
if (linked && !seeking) {
if (!wasSilent) {
Log.i(TAG, "presence: session live — advertising suspended")
wasSilent = true
}
stop()
// Short re-check, not a full bucket: if a disconnect
// callback is ever dropped we would otherwise stay dark
// (and invisible) for up to a whole rotation window.
withTimeoutOrNull(ACTIVE_RECHECK_MS) { rotationKick.receive() }
continue
}
stop()
startWith(AdvPayload.trustedPresence(token)) { result ->
if (wasSilent) {
Log.i(TAG, "presence: link down or seeking — advertising resumed")
wasSilent = false
}

val peers = presencePeersProvider?.invoke().orEmpty()
if (peers.isEmpty()) {
stop()
withTimeoutOrNull(ACTIVE_RECHECK_MS) { rotationKick.receive() }
continue
}

val nowSec = System.currentTimeMillis() / 1000
val bucket = Presence.currentBucket(nowSec, rotationWindowSec)
val onStart: (StartResult) -> Unit = { result ->
when (result) {
is StartResult.Started -> consecFails = 0
is StartResult.Failed -> {
consecFails++
Log.w(TAG, "trusted-presence advertise failed (${consecFails}x): ${result.reason}")
Log.w(TAG, "presence advertise failed (${consecFails}x): ${result.reason}")
if (consecFails == PRESENCE_FAIL_ALERT_AT) onError(result.reason)
}
}
}
// Sleep until ~5s past the next bucket boundary so we
// refresh just inside the new window — OR until a kick
// (connect/disconnect edge) asks for an immediate
// re-advertise with a re-evaluated mode. Receivers
// tolerate ±1 bucket so a small drift is fine.
val secondsIntoBucket = nowSec % rotationWindowSec
val sleepSec = rotationWindowSec - secondsIntoBucket + 5L
withTimeoutOrNull(sleepSec * 1000) { rotationKick.receive() }

if (peers.size == 1) {
stop()
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
// (connect/disconnect edge) asks for an immediate
// re-advertise with a re-evaluated mode. Receivers
// tolerate ±1 bucket so a small drift is fine.
val sleepSec = rotationWindowSec - (nowSec % rotationWindowSec) + 5L
withTimeoutOrNull(sleepSec * 1000) { rotationKick.receive() }
} else {
// Multiplex one pass over the peers, then re-evaluate the
// phase from the top (the session may have come back, or
// the peer set changed).
for (prs in peers) {
if (!isActive) break
stop()
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
// instead of finishing a cycle nobody is waiting for.
if (kicked != null) break
}
}
}
}
}

/**
* Single-peer entry point, kept for the pairing-completion path which has
* exactly one peer and no service running yet.
*/
fun startTrustedPresence(
prs: ByteArray,
scope: CoroutineScope,
rotationWindowSec: Long = 60L,
/** True while a peer is connected over GATT. See the rotation loop. */
isConnected: () -> Boolean = { false },
onError: (String) -> Unit = {},
) {
require(prs.size == 32) { "PRS must be 32 bytes" }
val only = listOf(prs.copyOf())
presencePeersProvider = { only }
startPresenceLoop(scope, rotationWindowSec, onError)
}

fun stop() {
val cb = activeCallback ?: return
try {
Expand Down Expand Up @@ -290,9 +367,24 @@ class Advertiser(private val context: Context) {
companion object {
private const val TAG = "VortexAdv"

/** Consecutive trusted-presence start failures before [startTrustedPresence]'s
/** Consecutive trusted-presence start failures before [startPresenceLoop]'s
* onError fires (the loop itself keeps retrying every bucket). */
private const val PRESENCE_FAIL_ALERT_AT = 3

/** 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. */
private const val MULTIPLEX_DWELL_MS = 1_500L

/** Re-check interval while advertising is suspended (session live) or
* there is nothing to advertise. Bounds how long a *dropped*
* disconnect callback can leave us silent and therefore invisible. */
private const val ACTIVE_RECHECK_MS = 15_000L
}
}

Expand Down
40 changes: 40 additions & 0 deletions android/app/src/main/java/com/vortex/a3/core/ble/Frame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,36 @@ object FrameType {
const val PHONE_FILES: Byte = 0x4F

const val FRAG: Byte = 0x4E
/** Session-ownership handoff (design doc §D4). A device may TRUST many
* peers but is ACTIVE with exactly one; this frame is how the two sides
* agree which. `sub` carries the kind ([FrameSub.HANDOFF_RELEASE] etc.),
* the AEAD payload an optional UTF-8 successor name for the UI.
* Additive: both sides log-and-ignore unknown frame types, so a peer
* without this build is unaffected. Mirrors Rust `ty::PEER_HANDOFF`. */
// 0x54, not 0x4F — see the note on the Rust side: upstream took 0x4F for
// PHONE_FILES, and PEER_HANDOFF is the one that has never shipped.
const val PEER_HANDOFF: Byte = 0x54
/** Ranged-filesystem request. `sub` carries the op
* ([com.vortex.a3.core.fs.FsOp]), the payload a JSON request — plus a
* binary byte tail for WRITE.
*
* BIDIRECTIONAL and symmetric: this phone both serves these (so the
* laptop can browse its storage) and sends them (so it can browse the
* laptop's). Neither the frame nor its handler names a side. See
* `docs/design/file-browsing.md`. Mirrors Rust `ty::FS_REQ`. */
const val FS_REQ: Byte = 0x50
/** Successful non-data reply — directory page, stat, open result, write
* ack. Carries `FsReply` JSON. Mirrors Rust `ty::FS_META`. */
const val FS_META: Byte = 0x51
/** Read result: `[id u32 BE][offset u64 BE][flags u8][bytes]`. Binary, not
* JSON: base64 would cost 33% on the protocol's hottest path. Mirrors
* Rust `ty::FS_DATA`. */
const val FS_DATA: Byte = 0x52
/** A definite failure for one request id (`FsErr` JSON). Every failing op
* answers with one — a file manager blocked on a read that will never be
* answered is this feature's worst outcome, so silence is never valid.
* Mirrors Rust `ty::FS_ERR`. */
const val FS_ERR: Byte = 0x53
const val ERROR: Byte = 0x7F
}

Expand All @@ -160,6 +190,16 @@ object FrameSub {
const val PONG: Byte = 0x02
const val ECHO_REQUEST: Byte = 0x01
const val ECHO_RESPONSE: Byte = 0x02
/** [FrameType.PEER_HANDOFF] kinds. Mirror Rust `ty::sub::HANDOFF_*`. */
/** "You are no longer my active peer" — sent by the side handing ownership
* over, so the receiver stops presenting itself as connected instead of
* finding out on next contact. */
const val HANDOFF_RELEASE: Byte = 0x01
/** Refused: another peer is already active. Explicit because silence is
* indistinguishable from packet loss and invites a retry loop. */
const val HANDOFF_BUSY: Byte = 0x02
/** Request to become the active peer. */
const val HANDOFF_CLAIM: Byte = 0x03
}

/** Header size in bytes. */
Expand Down
Loading
Loading