Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
88 changes: 84 additions & 4 deletions android/app/src/main/java/com/vortex/a3/core/lan/LanServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ class LanServer(
* the stack can gate the redundant BLE burst. */
var onBulkDelivered: (key: String, hash: String) -> Unit = { _, _ -> }

/** Fired after an instant-share FILE blob has been written to the peer,
* with the content token it pulled by. Closes the loop the outgoing-offer
* watchdog waits on: an offer is only really done once the laptop has the
* bytes. */
var onFileServed: (token: String) -> Unit = { }

/**
* Watermark-dataset provider for bulk-sync (currently `sms_history`):
* called with the peer's "I have everything up to [sinceMs]" watermark,
Expand Down Expand Up @@ -196,9 +202,68 @@ class LanServer(
}

private fun releasePerfLock() {
// A batch window ([keepLanHot]) owns the lock for its whole duration —
// the per-blob `finally` must not drop it between the laptop's rounds,
// which is exactly what let the radio park mid-batch.
if (lanHotJob?.isActive == true) return
try { if (perfLock?.isHeld == true) perfLock?.release() } catch (_: Exception) {}
perfLock = null
}

/** Runs for the duration of the current "a file pull is imminent" window. */
@Volatile
private var lanHotJob: Job? = null

/** Bumped per window so a superseded expiry (the scope is multi-threaded:
* an old timer can resume just as a new window opens) can't release the
* locks the new window is holding. */
@Volatile
private var lanHotGen: Int = 0

/** Whether the persistent BLE link is up, per [setBleLinked] — decides
* whether a closing hot window hands the multicast lock back or keeps it. */
@Volatile
private var bleLinked: Boolean = false

/**
* A phone→laptop file pull is imminent: file offers just went out over BLE
* and the laptop will dial us, ONE file per heartbeat round. Keep the LAN
* path hot for [ms] so those rounds land:
*
* - hold the throughput Wi-Fi lock, so the radio doesn't park between
* rounds and answer the laptop's cold TCP probe too late,
* - hold the multicast lock and re-announce, so the laptop can find our
* CURRENT address. While BLE is up we release that lock and answer no
* mDNS, which leaves the laptop's cached IP as its only guess — and a
* DHCP renew since the last successful handshake makes it a dead one.
*
* Repeat calls extend the window; only the first one re-announces. Returns
* true when this call OPENED the window, so a caller can pair it with a
* one-per-batch action (the BLE AppState push) instead of a per-file one.
*/
fun keepLanHot(ms: Long = HOT_WINDOW_MS): Boolean {
val first = lanHotJob?.isActive != true
acquirePerfLock()
acquireMulticast()
if (first) {
nudge()
Log.i(TAG, "LAN hot: radio + mDNS held for an incoming file pull")
}
val gen = ++lanHotGen
lanHotJob?.cancel()
lanHotJob = scope.launch {
kotlinx.coroutines.delay(ms)
if (gen != lanHotGen) return@launch
// Clear before releasing: [releasePerfLock] refuses to drop the
// lock while a window is live, and this one is over.
lanHotJob = null
releasePerfLock()
if (bleLinked) releaseMulticast()
Log.i(TAG, "LAN hot window over (no file pull for ${ms}ms)")
}
return first
}

/** Bound concurrent client handlers so a slow-loris attacker cannot
* pin every coroutine + socket FD on the device. */
private val clientSlots = Semaphore(MAX_CONCURRENT_CLIENTS)
Expand Down Expand Up @@ -310,8 +375,12 @@ class LanServer(
* is up; re-acquire the moment BLE drops and mDNS matters again.
*/
fun setBleLinked(linked: Boolean) {
bleLinked = linked
if (linked) {
releaseMulticast()
// Exception: a file pull in flight ([keepLanHot]) needs mDNS to
// answer, because the laptop's cached IP may be a dead lease and
// an unanswered browse leaves it nothing else to dial.
if (lanHotJob?.isActive != true) releaseMulticast()
} else if (acceptJob != null) {
acquireMulticast()
}
Expand Down Expand Up @@ -681,11 +750,16 @@ class LanServer(
Log.i(TAG, "bulk-sync: clipboard_file token=$token not found")
status.put(key, "nomatch")
} else {
acquirePerfLock()
try { sendChunked(FrameType.CLIPBOARD_FILE, blob) }
finally { releasePerfLock() }
// Extends the hot window: the laptop
// comes back for the NEXT queued file
// in a fresh round moments from now.
keepLanHot()
sendChunked(FrameType.CLIPBOARD_FILE, blob)
Log.i(TAG, "bulk-sync: clipboard_file sent (${blob.size} bytes)")
status.put(key, "sent")
try { onFileServed(token) } catch (e: Exception) {
Log.w(TAG, "onFileServed listener threw: ${e.message}")
}
}
continue
}
Expand Down Expand Up @@ -1027,6 +1101,12 @@ class LanServer(
/** Cap on concurrent client coroutines (handshake + idle). */
const val MAX_CONCURRENT_CLIENTS: Int = 16

/** How long [keepLanHot] keeps the radio + mDNS up after the last file
* offer or served blob. Generous on purpose: the laptop needs one
* heartbeat round PER queued file, and a round that misses its window
* costs far more battery in retries than the lock costs held. */
const val HOT_WINDOW_MS: Long = 60_000

/** Bulk-sync chunk payload size over TCP. Far larger than the 450B
* BLE chunks (TCP is reliable; only the 8KB frame cap binds) —
* a 160KB contact list is ~40 frames instead of ~360 notifies. */
Expand Down
29 changes: 29 additions & 0 deletions android/app/src/main/java/com/vortex/a3/service/VortexStack.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,23 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
* receive cipher. One-at-a-time keeps each burst at the proven 12ms rate. */
internal val companionSendMutex = kotlinx.coroutines.sync.Mutex()
internal var lanServer: LanServer? = null

/** Outgoing file offers not yet fetched by the laptop, keyed by content
* token. See [offerFileToLaptop] — the retry + "it never arrived" toast
* live there. */
internal val pendingOffers =
java.util.concurrent.ConcurrentHashMap<String, PendingOffer>()
internal var offerRetryJob: kotlinx.coroutines.Job? = null
internal val offerRetryKick = newOfferRetryKick()
/** Debounced "warm the LAN for the incoming pull" job — deferred so it
* can't put an mDNS re-announce and a STATE notify between two offers. */
internal var lanWarmJob: kotlinx.coroutines.Job? = null
/** True once this outage has been reported on screen, so a 5-file share
* doesn't stack 5 identical toasts. Cleared when a send gets through. */
@Volatile internal var offerUnreachableToasted: Boolean = false
/** Monotonic offer counter — the sequence the laptop's FIFO pull queue is
* compared against to spot an offer that was dropped in flight. */
@Volatile internal var offerSeq: Long = 0L
private var pairingOrchestrator: PairingOrchestrator? = null
private var reconnectOrchestrator: ReconnectOrchestrator? = null
internal var callFlowOrchestrator: com.vortex.a3.core.call.CallFlowOrchestrator? = null
Expand Down Expand Up @@ -120,6 +137,11 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
/** Invoked when fresh peer state arrives, so the notification refreshes. */
internal var onStateChanged: () -> Unit = {}

/** `elapsedRealtime()` of the last successful BLE AppState push. Lets the
* file-offer path skip a redundant one that would only compete with the
* offers for the notify queue. */
@Volatile internal var lastBleStatePushAtMs: Long = 0L

/** True once [start] has wired the stack (advertiser is up). */
fun isStarted(): Boolean = advertiser != null

Expand Down Expand Up @@ -546,6 +568,9 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
// Push our notes/todos set on (re)connect so the laptop merges +
// replies — converges both sides after an offline edit. Debounced.
com.vortex.a3.core.notes.NoteSync.markDirty()
// A file offer that couldn't go out while the link was down can go
// now — this is the whole reason it was kept.
kickOfferRetry()
// Re-send app icons after a reconnect (until the laptop has them
// cached) and flush any notifications buffered during the outage.
sentIconPkgs.clear()
Expand Down Expand Up @@ -703,6 +728,7 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
try {
val json = buildLocalAppState().toJsonBytes()
if (server.sendStateEncrypted(peerPub, json)) {
lastBleStatePushAtMs = android.os.SystemClock.elapsedRealtime()
Log.i(TAG, "state pushed over BLE")
}
} catch (e: Exception) {
Expand Down Expand Up @@ -830,6 +856,9 @@ class VortexStack(internal val service: Service) : VortexNotification.Host {
).run(firstFrame)
}
}
// A blob the laptop pulled is an offer that landed — stop tracking it
// (and don't toast a failure for a file that plainly arrived).
lan.onFileServed = { token -> noteFileServed(token) }
lanServer = lan
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,20 @@ internal fun VortexStack.startClipboardOutbound() {
o.put("token", token)
o.put("bytes", png.size)
val offer = o.toString().toByteArray(Charsets.UTF_8)
var delivered = false
for (peer in peerStore.list()) {
gattServer?.sendClipboardImageOfferEncrypted(peer.peerStaticPub, offer)
if (gattServer?.sendClipboardImageOfferEncrypted(peer.peerStaticPub, offer) == true) {
delivered = true
}
}
// Not retried, unlike a file: a clipboard image is transient, and by
// the time the link is back the user has copied something else. But
// don't claim it was offered when it wasn't.
if (delivered) {
Log.i(VortexStack.TAG, "clipboard image offered to laptop (${png.size} bytes, token=$token)")
} else {
Log.w(VortexStack.TAG, "clipboard image offer couldn't go out (BLE link down?)")
}
Log.i(VortexStack.TAG, "clipboard image offered to laptop (${png.size} bytes, token=$token)")
}
}

Expand All @@ -82,10 +92,12 @@ internal fun VortexStack.startClipboardOutbound() {
o.put("name", file.name)
o.put("mime", file.mime)
val offer = o.toString().toByteArray(Charsets.UTF_8)
for (peer in peerStore.list()) {
gattServer?.sendClipboardImageOfferEncrypted(peer.peerStaticPub, offer)
}
Log.i(VortexStack.TAG, "clipboard file offered to laptop ('${file.name}', ${file.bytes.size} bytes, token=$token)")
// Tracked until the laptop has actually FETCHED the bytes: the OFFER
// is a fire-and-forget BLE notify that goes nowhere on a dead link,
// and even a delivered one can sit unfetched. Retries, warms the LAN
// path on delivery, and toasts here if it ends up nowhere.
offerFileToLaptop(token, file.name, offer)
// Big file → bring up Wi-Fi Direct for a high-speed direct pull. Small
// files stay on the router path (the ~6s Wi-Fi switch isn't worth it).
if (file.bytes.size >= 4 * 1024 * 1024) maybeStartWifiDirect()
Expand Down
Loading