Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
73ce9af
feat(meeting): honest mic signal, live level meter, Tailscale auto-co…
WingedGuardian Oct 21, 2026
b2ca224
feat(meeting): local backup on the phone, 90/45-day retention, storag…
WingedGuardian Oct 21, 2026
90e1c8d
feat(meeting): mirror every recording to the edge, delete only on proof
WingedGuardian Oct 21, 2026
8e2b961
fix(meeting): resume with the recorded settings; only Tailscale count…
WingedGuardian Oct 21, 2026
35be056
Merge remote-tracking branch 'origin/feat/meeting-mic-honest-status' …
WingedGuardian Oct 21, 2026
124735f
fix(meeting): local backup timestamps, wake lock and recovery after r…
WingedGuardian Oct 21, 2026
7eb1746
Merge remote-tracking branch 'origin/main' into feat/meeting-mic-loca…
WingedGuardian Oct 21, 2026
ef0bfcb
fix(meeting): local backup never reports a short or unsynced file as …
WingedGuardian Oct 21, 2026
29883d8
fix(meeting): sync status stays live, migration is resumable, sync is…
WingedGuardian Oct 21, 2026
edb0929
fix(meeting): local backup keeps the last frame, one encoder at a tim…
WingedGuardian Oct 21, 2026
ac5442b
Merge remote-tracking branch 'origin/feat/meeting-mic-local-backup' i…
WingedGuardian Oct 21, 2026
a8b4e50
fix(meeting): send each recording only to the bridge it was captured for
WingedGuardian Oct 23, 2026
7881725
fix(meeting): a new token for the same bridge carries waiting recordi…
WingedGuardian Oct 23, 2026
de06bbf
fix(meeting): retry refused files after a new token; honest sync head…
WingedGuardian Oct 23, 2026
ee2d73f
feat(meeting): keep every recording on the phone for 90 days
WingedGuardian Oct 23, 2026
5f2b768
fix(meeting): a sync-scheduling or sidecar error never stalls recordi…
WingedGuardian Oct 23, 2026
9d652b2
Merge remote-tracking branch 'origin/feat/meeting-mic-sync' into feat…
WingedGuardian Oct 23, 2026
3d2f2ae
Merge remote-tracking branch 'origin/main' into feat/meeting-mic-keep…
WingedGuardian Oct 23, 2026
9242801
fix(meeting): never purge a recording while its edge copy is being re…
WingedGuardian Oct 23, 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
4 changes: 2 additions & 2 deletions clients/android-mic/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ android {
// recordings live; on older releases an app with storage permission could.
minSdk = 30
targetSdk = 34
versionCode = 6
versionName = "0.6.0"
versionCode = 7
versionName = "0.6.1"

// Surfaced to Kotlin as BuildConfig.MEETING_WS_URL / MEETING_TOKEN.
buildConfigField("String", "MEETING_WS_URL", "\"$meetingWsUrl\"")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ class LocalRecorder(
// it resumes only above RESUME_BYTES, so it does not flap at the edge.
const val FLOOR_BYTES = 300L * 1024 * 1024
const val RESUME_BYTES = 400L * 1024 * 1024
// Below this, retention may also remove recordings older than 45 days.
// Below this, retention frees room early by removing copies the edge holds, only until there
// is room again. Unsynced recordings are never removed early.
const val LOW_STORAGE_BYTES = 1024L * 1024 * 1024
private const val STORAGE_CHECK_MS = 30_000L
private const val SYNC_MS = 5_000L
Expand Down Expand Up @@ -96,31 +97,46 @@ class LocalRecorder(
*/
fun purge(context: Context, nowUtcMs: Long = System.currentTimeMillis()): Int {
val d = dir(context)
val r = purgeDir(d, nowUtcMs) { allocatableBytes(context, d) >= LOW_STORAGE_BYTES }
// Never silent: a recording that expired before reaching the edge is reported.
SegmentSync.noteExpiredUnsynced(context, r.expiredUnsynced)
return r.removed
}

class Purged(val removed: Int, val expiredUnsynced: Int)

/** [purge] on a folder, with [hasRoom] answering whether storage is fine (Context-free, tested). */
fun purgeDir(d: File, nowUtcMs: Long, hasRoom: () -> Boolean): Purged {
// Finish any interrupted rename first, so its sidecar is not swept as an orphan below.
SegmentSync.migrateLegacyNames(d)
val low = allocatableBytes(context, d) < LOW_STORAGE_BYTES
val files = d.listFiles { f -> f.name.endsWith(".aac") }.orEmpty()
.filter { SegmentSync.baseOf(it) !in openBases }
.associateBy { it.name }
val segments = files.values
.map { RetentionPolicy.Segment(it.name, segmentStartMs(it)) }
.map {
val checked = File(d, SegmentSync.baseOf(it) + ".checked").exists()
RetentionPolicy.Segment(it.name, segmentStartMs(it), SegmentSync.isSynced(it), checked)
}
.filter { it.startUtcMs <= nowUtcMs + RetentionPolicy.DAY_MS }
val doomed = RetentionPolicy.toDelete(segments, nowUtcMs, low)
var expiredUnsynced = 0
var removed = 0
SegmentSync.folderLock.lock()
try {
for (s in doomed) {
RetentionPolicy.sweep(segments, nowUtcMs, hasRoom) { s ->
val f = files.getValue(s.id)
if (!f.exists()) continue // renamed by sync since the listing; judged next time
// Renamed by sync since the listing (or already gone): judged next time. This is what
// keeps a copy the edge disowned (renamed back to X.aac) from going as a held one.
if (!f.exists()) return@sweep
Comment on lines +125 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent purge from racing a negative edge recheck

When storage is low and /have reports false, runPass receives that answer before acquiring folderLock to rename the recording back to .aac. A concurrent purge can acquire the lock first, see the still-existing .synced.aac here, and delete it as an early-retention candidate; the subsequent rename then finds no file, leaving neither the edge nor the phone with the recording. The existence check only handles a rename that has already completed, so the negative recheck and purge deletion need coordination that covers this window.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9242801: the pass claims the recording (SegmentSync.verifying) under folderLock before calling /have and releases it only after acting on the answer; purgeDir skips any claimed recording under the same lock. A "no" therefore always finds the file and re-queues it. Every sync call also has a 10-minute overall timeout so a claim cannot be held indefinitely. Tests: aRecordingIsClaimedAgainstPurgeWhileTheEdgeIsAsked, purgeLeavesARecordingAloneWhileItsEdgeCopyIsBeingRechecked.

// Its edge copy is being re-checked: a "no" would make this the only copy.
if (SegmentSync.baseOf(f) in SegmentSync.verifying) return@sweep
// The sidecar goes only once the audio is really gone: a recording that survives a
// failed delete keeps its timing.
if (!f.delete() && f.exists()) continue
if (!f.delete() && f.exists()) return@sweep
if (!SegmentSync.isSynced(f)) expiredUnsynced += 1
SegmentSync.deleteCompanions(d, SegmentSync.baseOf(f))
removed += 1
}
d.listFiles { f -> f.name.endsWith(".json") || f.name.endsWith(".dest") }.orEmpty()
d.listFiles { f -> SegmentSync.COMPANIONS.any { f.name.endsWith(it) } }.orEmpty()
.filter { it.nameWithoutExtension !in openBases }
.filter {
!File(d, it.nameWithoutExtension + ".aac").exists() &&
Expand All @@ -131,9 +147,7 @@ class LocalRecorder(
} finally {
SegmentSync.folderLock.unlock()
}
// Never silent: a recording that expired before reaching the edge is reported.
SegmentSync.noteExpiredUnsynced(context, expiredUnsynced)
return removed
return Purged(removed, expiredUnsynced)
}

/** One line describing what is stored on this phone, read straight from the folder. */
Expand All @@ -145,7 +159,7 @@ class LocalRecorder(
val newest = files.maxByOrNull { segmentStartMs(it) }
val frames = newest?.let { Adts.frameOffsets(it.readBytes()).size } ?: 0
val secs = frames.toLong() * Adts.SAMPLES_PER_FRAME / SAMPLE_RATE
return "On this phone: %d recording files, %.1f MB, oldest %d days (kept 90). Newest: %d:%02d of valid audio."
return "On this phone: %d recording files, %.1f MB, oldest %d days (kept up to 90). Newest: %d:%02d of valid audio."
.format(files.size, mb, oldestDays, secs / 60, secs % 60)
}

Expand Down Expand Up @@ -574,7 +588,7 @@ class LocalRecorder(
val d = dir(context)
var free = allocatableBytes(context, d)
if (free < FLOOR_BYTES) {
// Free what the retention rule allows (45-day floor under pressure), then re-measure.
// Free what the retention rule allows under pressure, then re-measure.
try { purge(context) } catch (_: Exception) {}
free = allocatableBytes(context, d)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ internal fun syncLine(s: SegmentSync.Status): String {
else -> "Synced to Genesis: all recordings ✓"
},
)
if (s.synced > 0) parts += "${s.synced} confirmed on Genesis (phone copy kept 7 days, then re-checked)"
if (s.synced > 0) parts += "${s.synced} confirmed on Genesis (phone copy kept 90 days unless storage runs low)"
if (s.rejected > 0) parts += "⚠ ${s.rejected} refused by Genesis, kept on phone"
if (s.expiredUnsynced > 0) parts += "⚠ ${s.expiredUnsynced} expired before syncing"
if (s.unroutable > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,48 @@ package com.genesis.meetingmic

/**
* Which local recordings to delete (owner rule):
* - anything older than [keepDays] (90) goes;
* - when storage is low, anything older than [lowStorageKeepDays] (45) also goes;
* - nothing newer than [lowStorageKeepDays] is ever deleted, however full the phone is.
* - every recording is kept [keepDays] (90), even after the edge confirms it holds a copy;
* - anything older than that goes;
* - under storage pressure, copies the edge confirmed holding may go early, only until there is room
* again (re-confirmed a week later first, then the rest; oldest first within each);
* - an unsynced recording is the only copy and is never deleted early, however full the phone is:
* the local backup stops instead (see [LocalRecorder]).
*
* Nothing is pinned: a later stage that finds its audio purged reports it as lost, visibly. Pure, so
* the rule is unit-tested.
*/
object RetentionPolicy {
const val DAY_MS = 24L * 60 * 60 * 1000

data class Segment(val id: String, val startUtcMs: Long)
/**
* [synced]: the edge returned a receipt proving it stored these exact bytes. [checked]: it said
* so again a week later.
*/
data class Segment(
val id: String,
val startUtcMs: Long,
val synced: Boolean = false,
val checked: Boolean = false,
)

fun toDelete(
segments: List<Segment>,
nowUtcMs: Long,
lowStorage: Boolean,
keepDays: Int = 90,
lowStorageKeepDays: Int = 45,
): List<Segment> {
val cutoff = nowUtcMs - (if (lowStorage) lowStorageKeepDays else keepDays) * DAY_MS
return segments.filter { it.startUtcMs < cutoff }.sortedBy { it.startUtcMs }
/** Past the retention period: always deleted, oldest first. */
fun expired(segments: List<Segment>, nowUtcMs: Long, keepDays: Int = 90): List<Segment> =
segments.filter { it.startUtcMs < nowUtcMs - keepDays * DAY_MS }.sortedBy { it.startUtcMs }

/** What may go early under storage pressure, in the order it goes: only copies the edge holds. */
fun underPressure(segments: List<Segment>, nowUtcMs: Long, keepDays: Int = 90): List<Segment> =
segments.filter { it.synced && it.startUtcMs >= nowUtcMs - keepDays * DAY_MS }
.sortedWith(compareBy({ !it.checked }, { it.startUtcMs }))

/**
* Removes everything expired, then the early candidates one at a time while [hasRoom] says no,
* asking again after each removal.
*/
fun sweep(segments: List<Segment>, nowUtcMs: Long, hasRoom: () -> Boolean, remove: (Segment) -> Unit) {
expired(segments, nowUtcMs).forEach(remove)
for (s in underPressure(segments, nowUtcMs)) {
if (hasRoom()) break
remove(s)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

/**
* Mirrors every closed local recording to the edge, and deletes the phone copy only on proof.
* Mirrors every closed local recording to the edge. It never deletes a recording: the phone keeps
* every copy for 90 days, and only [LocalRecorder.purge] removes them (see [RetentionPolicy]).
*
* The recordings folder IS the queue: `X.aac` is waiting, `X.synced.aac` is held by the edge. No
* other state can drift from the files. One pass ([runPass]):
*
* 1. Upload each waiting, closed file. The edge answers `stored` only after writing the bytes,
* re-reading them from disk and re-hashing them; the file becomes `X.synced.aac` only when that
* hash AND length equal the phone's own. Anything else leaves it waiting.
* 2. For a synced file past the grace period (or sooner when storage is low), ask the edge again
* whether it still holds those exact bytes. Only a yes deletes the phone copy; a no puts the file
* back in the queue; no answer leaves it alone.
* 2. A week after confirmation, ask the edge once more whether it still holds those exact bytes. A
* yes leaves an `X.checked` mark and the phone copy stays; a no puts the file back in the queue;
* no answer means asking again on a later pass.
*
* The owner's rule: when there is any doubt, keep the file (the 90-day retention still applies).
* The owner's rule: when there is any doubt, keep the file.
*
* Each recording goes to the bridge it was captured for, never to whatever is configured later: the
* capture run registers its endpoint and every segment gets a `X.dest` file naming it (see
Expand All @@ -37,7 +38,7 @@ import kotlin.concurrent.withLock
* endpoint saved first after the upgrade.
*/
object SegmentSync {
const val GRACE_MS = 7L * 24 * 60 * 60 * 1000
const val RECHECK_MS = 7L * 24 * 60 * 60 * 1000
private const val SETTLE_MS = 10_000L // never touch a file modified this recently
private const val REJECT_RETRY_MS = 24L * 60 * 60 * 1000
private const val FILE_RETRY_MS = 10L * 60 * 1000 // a file the edge could not take right now
Expand All @@ -53,6 +54,12 @@ object SegmentSync {
/** Guards every rename/delete in the recordings folder (sync and retention). */
val folderLock = ReentrantLock()

/**
* Recordings whose edge copy is being re-checked right now. Retention never deletes one: if the
* edge answers "no", the phone copy is the only one left.
*/
val verifying: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()

/** One pass at a time in this process, whichever job or kick started it. */
private val passLock = ReentrantLock()

Expand Down Expand Up @@ -95,16 +102,18 @@ object SegmentSync {
else -> Outcome.REJECTED
}

fun deleteDue(syncedAtMs: Long, nowMs: Long, lowStorage: Boolean): Boolean =
lowStorage || nowMs - syncedAtMs >= GRACE_MS
fun recheckDue(syncedAtMs: Long, nowMs: Long): Boolean = nowMs - syncedAtMs >= RECHECK_MS

/** `seg_x.synced.aac` and `seg_x.aac` share the base `seg_x` (and its sidecar `seg_x.json`). */
fun baseOf(file: File): String = file.name.removeSuffix(".aac").removeSuffix(".synced")

fun isSynced(file: File): Boolean = file.name.endsWith(".synced.aac")

/** The small files that belong to a recording and go wherever it goes. */
private val COMPANIONS = listOf(".json", ".dest")
/**
* The small files that belong to a recording and go wherever it goes: its sidecar, its
* destination, and the mark that the edge re-confirmed it a week after the upload.
*/
val COMPANIONS = listOf(".json", ".dest", ".checked")

/** Deletes a recording's companions; call only once its audio is gone. */
fun deleteCompanions(dir: File, base: String) {
Expand Down Expand Up @@ -168,6 +177,9 @@ object SegmentSync {
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
// Bounds a whole call, so an edge that trickles bytes can never hold a re-check claim (or
// a pass) open indefinitely; generous enough for a segment upload on a slow link.
.callTimeout(10, TimeUnit.MINUTES)
// A redirect could point the recording at a plain http address: never follow one.
.followRedirects(false)
.followSslRedirects(false)
Expand Down Expand Up @@ -266,16 +278,14 @@ object SegmentSync {
/** One sync pass for the app. Returns true only when nothing closed is left unsynced. */
fun runOnce(context: Context): Boolean {
val dir = LocalRecorder.dir(context)
val low = LocalRecorder.allocatableBytes(context, dir) < LocalRecorder.LOW_STORAGE_BYTES
return runPass(dir, router(context, dir), prefsState(context), low, defaultHttp, System::currentTimeMillis)
return runPass(dir, router(context, dir), prefsState(context), defaultHttp, System::currentTimeMillis)
}

// ── the pass (Context-free, tested against a fake edge) ─────────────────
fun runPass(
dir: File,
route: (File) -> String?,
state: State,
lowStorage: Boolean,
http: OkHttpClient,
now: () -> Long,
): Boolean = passLock.withLock {
Expand Down Expand Up @@ -315,29 +325,42 @@ object SegmentSync {
}
} while (uploadedAny)

// Synced files past their grace period: re-verify, then delete or re-queue.
// Synced files a week past confirmation: re-verify once; a no re-queues. Nothing is deleted.
var allDone = true
for (f in dir.listFiles { x -> isSynced(x) }.orEmpty()) {
if (!deleteDue(f.lastModified(), now(), lowStorage)) continue
val mark = File(dir, baseOf(f) + ".checked")
if (mark.exists()) continue
if (!recheckDue(f.lastModified(), now())) continue
// Asked only of the edge it was sent to: another edge's "yes" proves nothing.
val ep = route(f)
// No destination: nothing a retry can do, so it alone does not keep the job retrying;
// the copy stays under the 90-day rule. An edge that did not answer is worth a retry.
// No destination: nothing a retry can do, so it alone does not keep the job retrying.
// An edge that did not answer is worth a retry.
if (ep == null) continue
if (ep in unreachable) {
allDone = false
continue
}
when (stillHeld(http, ep, f)) {
true -> folderLock.withLock {
if (f.exists() && f.delete()) deleteCompanions(dir, baseOf(f))
}
false -> folderLock.withLock {
// The edge no longer holds these bytes: back into the queue.
if (f.exists()) f.renameTo(File(dir, baseOf(f) + ".aac"))
allDone = false
// Claimed under the lock: a purge either removed the file first (it is skipped here) or sees
// the claim and leaves the file alone until the answer has been acted on.
val base = baseOf(f)
val claimed = folderLock.withLock { f.exists() && verifying.add(base) }
if (!claimed) continue
try {
when (stillHeld(http, ep, f)) {
true -> folderLock.withLock {
// Only beside audio that is still there, so no mark outlives its recording. A mark
// that cannot be written just means asking again on a later pass.
if (f.exists()) try { mark.createNewFile() } catch (e: IOException) {}
}
false -> folderLock.withLock {
// The edge no longer holds these bytes: back into the queue.
if (f.exists()) f.renameTo(File(dir, baseOf(f) + ".aac"))
allDone = false
}
null -> allDone = false
}
null -> allDone = false
} finally {
verifying.remove(base)
}
}
// Not done while ANY closed file is unsynced, including one too fresh to touch this pass (the
Expand Down Expand Up @@ -437,7 +460,7 @@ object SegmentSync {
return folderLock.withLock {
val synced = File(dir, "$base.synced.aac")
if (f.exists() && f.length() == data.size.toLong() && f.renameTo(synced)) {
synced.setLastModified(nowMs) // the grace period counts from confirmation
synced.setLastModified(nowMs) // the week before the re-check counts from confirmation
Outcome.CONFIRMED
} else {
Outcome.RETRY_FILE
Expand Down
Loading
Loading