Repository navigation
feat(meeting): mirror every recording to the edge, delete only on proof - #57
Conversation
…nnect - MicLevelMonitor: fail-closed Mic signal. Green only with a non-zero frame in the last 2 s; red after 5 s of all-zero audio or 5 s with no frames, and at once when Android reports the capture silenced (AudioRecordingConfiguration, API 29+). Re-judged on a 200 ms clock so a stalled capture loop cannot stay green. - Live mic meter in the app and a level bar in the notification; the notification leads with the worst signal. One alert per incident: the mic alert clears only after 3 s of continuous sound. - TailscaleLink: when no VPN is up, ask Tailscale to connect through its exported CONNECT_VPN receiver; after 20 s without a VPN, alert with a tap-to-open action. The network note shows only while delivery is failing. - A sticky restart after an OS kill no longer tries to reopen the mic (Android 14+ refuses background microphone FGS starts); it posts a notification that opens the app, which restarts capture through the normal Start path. - Setup checklist (Always-on VPN, never-sleeping apps, mic access tile) and a banner when notifications are disabled. - Version 0.4.0 (4) with the build time shown.
…e floor - LocalRecorder: every capture is also written on the phone, independent of the live stream. The capture loop hands frames to a bounded queue (never blocks); a per-run writer thread encodes AAC-LC 32 kbps 16 kHz mono and writes raw ADTS frames in 5-minute segments, each with a sidecar written at open (UTC start, run id, sample position). Dropped frames are carried in order and encoded as silence, so positions track capture time. - Retention: older than 90 days is deleted; below 1 GB allocatable, older than 45 days too; never anything newer than 45 days. Runs at each capture start, under storage pressure, and on every app open. Open and future-dated segments are never touched. - Storage floor: below 300 MB the backup pauses (resumes above 400 MB) and the live stream continues. Encoder failures retry after 30 s, permanent after 3. - Fail-closed Local backup signal (green only while bytes reach disk) with a loud alert when it goes off; each closed file is re-read and its valid audio length shown; an "On this phone" summary reads the folder directly. - Version 0.5.0 (5).
- SegmentSync: the recordings folder is the queue (X.aac waiting, X.synced.aac confirmed). Each closed segment, including the short final one at Stop, is uploaded; it becomes synced only when the edge's re-read sha256 and length equal the phone's own. After a 7-day grace (sooner only when storage is low) the phone asks the edge again (/have, re-hashed from disk): yes deletes, no re-queues, no answer keeps. Any doubt keeps the file. - Segment names carry a per-run UUID (the in-memory run counter restarts with the process and could collide); older names are migrated once. - SyncWorker (WorkManager): a kick on every closed file and app open (REPLACE, so a recovered network is used at once) plus an hourly catch-up that survives restarts and reboots; one pass at a time. A file the edge cannot take now does not block the others. A loud alert if anything has waited over a day; the status shows waiting / confirmed / refused / expired-before-sync. - Retention purge shares a folder lock with sync and reports unsynced expiries. Version 0.6.0 (6).
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Warning Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
| val text = try { | ||
| LocalRecorder.summary(app) + "\n" + syncLine(SegmentSync.status(app)) | ||
| } catch (e: Exception) { | ||
| "On this phone: could not read recordings" | ||
| } |
There was a problem hiding this comment.
🟡 Sync status stays stale after uploads
When SyncWorker finishes an upload while the screen stays open, storageSummary still shows the old backlog. Only activity resume or capture stop refreshes it.
Learn more
The storage summary is calculated once in a background thread when the activity resumes, or two seconds after capture stops. SyncWorker can complete long after either refresh, and it never tells the activity that file state changed. The view therefore continues to report waiting files even when their uploads succeeded, or shows confirmed copies after they have been deleted.
Example: The user opens the screen with three waiting recordings and keeps it open. The worker uploads all three a minute later; the screen still reports three waiting until the activity resumes.
Recommended fix: Observe worker completion or file-state changes from MainActivity, and call refreshStorageSummary(false) after each completed pass while the activity is active.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 29883d8: the sync line refreshes whenever a sync job (the kick or the hourly one) changes state, while the screen is open.
There was a problem hiding this comment.
Already addressed: MainActivity observes both sync jobs via SyncWorker.observe and refreshes the storage summary on every state change.
| if (f.renameTo(File(dir, fresh + (if (isSynced(f)) ".synced.aac" else ".aac")))) { | ||
| File(dir, "$base.json").takeIf { it.exists() }?.renameTo(File(dir, "$fresh.json")) | ||
| } |
There was a problem hiding this comment.
🟡 Interrupted migration loses recording metadata
If the app stops between migrateLegacyNames renames, the migrated audio keeps its old sidecar name. Later passes cannot match them, so retention deletes the orphan metadata and uploads audio without timing information.
Learn more
Legacy recordings have separate AAC audio and JSON metadata files. Migration renames the audio first and its sidecar second. An app kill or a failed sidecar rename between those operations leaves the new audio with no matching JSON. Subsequent calls cannot detect that the new name came from a legacy file, and purge removes the orphan sidecar after an hour.
Example: seg_20261021T180000_r1_s0.aac is renamed to a UUID-based name, then the process dies. The next upload sends no meta query parameter, and the original JSON is deleted as an orphan.
Recommended fix: Make migration recoverable across interruption: persist the old-to-new mapping or recover the old sidecar from a durable marker before treating migrated audio as ready to upload.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 29883d8: a legacy name now migrates to a name derived from the old one, so an interrupted migration finishes on the next call and the sidecar follows its audio. Retention runs the migration before sweeping orphan sidecars.
| if (t.isEmpty()) return null | ||
| val https = when { | ||
| b.startsWith("wss://") -> "https://" + b.removePrefix("wss://") | ||
| b.startsWith("ws://") -> "http://" + b.removePrefix("ws://") |
There was a problem hiding this comment.
Fixed in 29883d8: sync uses only a wss:// (https) address and follows no redirects; with a plain ws:// address the files wait on the phone and the status says why.
…s as the tailnet - Tap-to-resume restores the persisted capture settings (endpoint, token, model) instead of the build defaults a fresh screen shows; with none saved it refuses to start rather than silently using defaults. - The tailnet check requires a VPN network carrying a Tailscale address (the RFC 6598 /10 or Tailscale's IPv6 /48), so another VPN no longer suppresses auto-connect and the Tailscale alert. - The meter drops to zero once sound is no longer fresh. - Stop clears the network note before publishing the stopped state. - The notifications-off banner also catches the app-level switch and a muted failure channel.
…into feat/meeting-mic-local-backup # Conflicts: # clients/android-mic/app/src/main/java/com/genesis/meetingmic/MainActivity.kt
…eview - Each queued frame carries its capture time; a segment's UTC start is when its first audio was captured, not when the lagging writer encoded it. - Filling dropped audio with silence stops as soon as the writer goes off. - The writer holds its own wake lock in renewed short leases for its whole run, so the drain after Stop stays awake however long the meeting was. - A write error resumes once free space is back above the 300 MB floor; only a full disk waits for the 400 MB margin. - An encoder that fails to configure is released; a missing encoder counts as an encoder failure, not a storage failure. - The storage summary is refreshed again after the final file has closed.
…l-backup # Conflicts: # clients/android-mic/app/build.gradle.kts # clients/android-mic/app/src/main/java/com/genesis/meetingmic/MainActivity.kt # clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicStreamService.kt # clients/android-mic/app/src/main/java/com/genesis/meetingmic/ProblemAlerts.kt # clients/android-mic/app/src/main/res/layout/activity_main.xml # clients/android-mic/app/src/main/res/values/strings.xml
…saved - Audio dropped after the last queued frame is filled with silence at Stop, so the file is as long as the capture. - An encoder that does not finish a segment (no end-of-stream within the bound, or no input slot for it) is an encoder failure, not a finished file. - A failed fsync turns the backup off with an alert instead of being ignored; a failed close reports the last file as not fully saved. - minSdk 30: from Android 11 other apps cannot read this app's external files folder, where recordings are kept.
… encrypted only Merges the updated local-backup base, then: - The sync line refreshes whenever a sync job changes state, so it is current while the screen stays open. - Legacy segment names migrate to a name derived from the old one, so an interrupted migration finishes next time and the sidecar follows its audio; retention runs the migration before sweeping orphan sidecars. - Sync uses only a wss:// (https) address; with a plain ws:// address the files wait on the phone and the status says why. - The upgrade fallback saves the last capture's settings as the sync endpoint, not the form's build defaults.
…e, never overwrites - The local writer is finished when the capture job completes, so the read in flight at Stop is kept; a job cancelled before it started still finishes its writer. - Each new writer waits for the previous one in this process to release its encoder, across service restarts. - A segment is never opened over an existing recording. - A segment that loses its tail at a 5-minute rollover is reported and recording continues with a fresh encoder; only repeated failures stop it. - Retention removes a sidecar only once its audio is really gone.
…nto feat/meeting-mic-sync # Conflicts: # clients/android-mic/app/src/main/java/com/genesis/meetingmic/LocalRecorder.kt
|
Handoff to a closing session (state 2026-10-22):
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac5442b85a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (token.isBlank() || !wsBase.trim().startsWith("wss://")) return | ||
| context.getSharedPreferences(CREDS, Context.MODE_PRIVATE).edit() | ||
| .putString("ws_base", wsBase.trim()).putString("token", token.trim()).apply() |
There was a problem hiding this comment.
Bind queued recordings to their capture endpoint
When the user changes the bridge or token while older .aac files are waiting, these single global credentials are overwritten and runPass sends every queued recording to the new endpoint; because MainActivity also permits ws://, selecting one after a prior wss:// session leaves the old credentials in place and sends new recordings to the old bridge. This can mirror audio to the wrong server and also makes /have verify previously synced files against the wrong server, so the destination needs to be recorded per segment rather than kept as one global value.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a8b4e50, refined in 7881725. Each capture run registers its endpoint, and every segment writes an fsync'd X.dest naming it when the segment opens. Uploads and /have checks route per file (SegmentSync.runPass takes a per-file route), so changing the bridge never redirects queued recordings. A ws:// capture is marked as having no secure destination: it stays on the phone and is reported. Recordings from older builds bind once to the endpoint those builds actually used (the old global ws_base/token), and only if they were recorded before the upgrade. The id is the bridge address, so a new token for the same address carries waiting recordings over while a different address never receives them. An unreachable bridge no longer stops the pass for the others. Tests: eachRecordingIsSentOnlyToItsOwnDestination, aRecordingWithNoSecureDestinationIsNeverSent, theStillHeldCheckAsksTheRecordingsOwnBridge, theOldBuildsEndpointBecomesTheBacklogsDefaultAheadOfANewBridge, aNewRecordingThatLostItsDestinationIsNotGivenTheDefault, aNewTokenForTheSameBridgeCarriesTheWaitingRecordingsOver.
| private const val NOTIF_ID = 7 | ||
| private const val PERIODIC = "segment-sync-hourly" | ||
|
|
||
| private val online = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() |
There was a problem hiding this comment.
Schedule stuck alerts without requiring connectivity
Both the immediate and hourly workers use this CONNECTED constraint, but warnIfStuck is only called from inside the worker. If a recording is made during an extended outage or the phone stays offline for more than a day, neither worker runs and the promised one-day warning is never posted; by the time connectivity returns, the upload may complete and cancel the need for any alert. The notification check needs an unconstrained schedule separate from the network-constrained upload.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a8b4e50. StuckCheckWorker is a periodic WorkManager job (every 3 h) with no constraints. It raises the alert for a recording waiting over a day, and also for a recording with no secure destination for over a day, and clears it when nothing is stuck. A phone offline for days still gets the warning within about 27 h. Test: recordingsThatCanNeverSyncRaiseTheAlertAfterADay.
There was a problem hiding this comment.
Already addressed in a8b4e50: StuckCheckWorker runs every 3 h with no network constraint and posts the same alert, so an offline phone still gets it.
|
Owner ruling for whoever closes this (2026-10-22): the phone will keep every recording the full 90 days even after the edge confirms it, deleting early only under storage pressure (synced files first). That replaces this PR's 7-day post-confirmation delete and lands as a follow-up PR right after this one merges. Merge this PR as-is, but do NOT build a phone APK from main between this merge and that follow-up, or the installed build would delete phone copies after 7 days. |
Each capture run registers its endpoint, and every segment records it in a small X.dest file when it opens. Uploads and the later "still held" checks go only to that endpoint, so changing the bridge or token no longer redirects queued recordings. A recording captured over ws:// has no secure destination: it stays on the phone and is reported. Recordings from earlier builds take the endpoint those builds were using, once, and only if they were made before the upgrade. One unreachable bridge no longer holds back uploads to another. The stuck-recording alert now also runs from a job with no network requirement, so a phone offline for days still gets it, and recordings that can never sync raise it too.
…ngs over The destination id is now the bridge address alone. Rotating the token on the same address re-points recordings still waiting to the new token, instead of leaving them refused until they expire. A different address is still a different destination and never receives them.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
When final fsync or encoder draining fails, these branches leave the nonempty .aac file closed and eligible for eventual upload but bypass onSegmentClosed(), since that callback exists only in the success else branch. If this happens to the final segment, no immediate work is enqueued and the surviving audio waits for the periodic worker—potentially about an hour—rather than being mirrored promptly; enqueue sync for every retained nonempty closed file, including degraded ones.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| val rejectedAt = state.getLong("rejected:" + f.name) | ||
| if (rejectedAt > 0 && now() - rejectedAt < REJECT_RETRY_MS) continue |
There was a problem hiding this comment.
Retry rejected files after credentials change
When an upload is rejected because the token is expired or incorrect, this timestamp suppresses the file for 24 hours. Registering a replacement token for the same bridge updates the endpoint credential but never clears or versions these per-file rejection entries, so the kick caused by the next recording still skips the waiting files even though they could now upload successfully. Invalidate the rejection delay when that endpoint's credentials change.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de06bbf: when registerEndpoint stores a different credential for a known bridge, every rejected: refusal delay is cleared, so refused files are tried again on the next pass. Test: aNewTokenLiftsTheRefusalDelaySoRefusedFilesRetryAtOnce.
| s.waiting == 0 && s.synced == 0 -> "Synced to Genesis: nothing waiting" | ||
| s.waiting == 0 -> "Synced to Genesis: all recordings ✓" |
There was a problem hiding this comment.
Account for unroutable recordings in the success headline
When the phone contains only recordings captured without a secure destination, status() reports waiting == 0 and a positive unroutable count, so this headline says either “nothing waiting” or, if any older confirmed copy also exists, “all recordings ✓”. The warning appended below contradicts that success message and can falsely reassure the user that every recording synced; select the success headline only when the unroutable count is also zero.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de06bbf: the headline shows a warning, never a success, while unroutable > 0 (syncLine is now a testable top-level function). Test: theHeadlineIsNeverASuccessWhileSomeRecordingsCannotSync.
| } else { | ||
| warnIfStuck(applicationContext) |
There was a problem hiding this comment.
Clear the alert when no recording remains stuck
When a previously day-old file uploads but the pass still returns false because another file is merely too fresh, temporarily retrying, or awaiting a /have response, warnIfStuck() finds no day-old backlog and returns without cancelling the existing high-importance notification. The obsolete “not synced for over a day” alert can therefore remain for up to the next three-hour stuck-check run; reconcile or clear the notification in this branch when stuckText() is now null.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in de06bbf: warnIfStuck now cancels the alert when stuckText is null, and SyncWorker calls it on every pass (a finished pass no longer clears an alert that still applies, e.g. for recordings with no secure destination).
…line A new token for a known bridge now lifts the day-long refusal delay, so files refused under the old token are tried again on the next pass. The sync headline no longer reads "all recordings" or "nothing waiting" while some recordings have no secure destination and can never sync. The stuck-recording alert is raised or cleared by what is actually stuck, on every pass: it is cleared once nothing is stuck, and a pass that finished no longer clears an alert that still applies.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de06bbf3d8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| else -> { | ||
| verify(aac) | ||
| openBases.remove(base) | ||
| onSegmentClosed() // a closed file is ready to mirror to the edge |
There was a problem hiding this comment.
Isolate recording from sync-enqueue failures
When a normal five-minute segment closes, this callback executes inside write()'s broad encoder exception handler. If SyncWorker.kick() throws—for example because WorkManager's database is temporarily unavailable or storage is full—the completed recording is misclassified as an encoder failure, Off.ENCODER pauses the backup, and subsequent captured audio is discarded until the retry interval expires. Catch callback failures separately so mirror scheduling cannot interrupt local recording.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 5f2b768: the sync kick in closeSegment is wrapped in its own try/catch, so a WorkManager failure is logged and never read as an encoder failure.
| 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 |
There was a problem hiding this comment.
Require the confirmation timestamp update to succeed
When setLastModified() returns false, the upload is still reported as confirmed while the renamed file retains its original capture timestamp. For an older backlog, the synced-file scan later in the same pass can therefore consider the seven-day grace period already elapsed and delete the phone copy after /have succeeds, rather than retaining it for seven days after confirmation. Treat a failed timestamp update as a retry or persist the confirmation time elsewhere.
Useful? React with 👍 / 👎.
| val data = try { f.readBytes() } catch (e: IOException) { return Outcome.RETRY_FILE } | ||
| val sha = sha256(data) | ||
| val base = baseOf(f) | ||
| val side = File(dir, "$base.json").takeIf { it.exists() }?.readText() |
There was a problem hiding this comment.
Keep unreadable metadata from blocking the queue
When an existing JSON sidecar cannot be read, this unguarded readText() throws out of upload() and aborts the entire sync pass. Because the next retry encounters the same sorted file again, every later recording remains blocked indefinitely even though metadata is optional and a missing sidecar is already allowed. Catch the sidecar read failure and either retry only this file or upload it without metadata so the rest of the queue can proceed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 5f2b768: an unreadable sidecar returns RETRY_FILE for that file only, so the queue behind it proceeds. After a day the audio uploads without the metadata, so a broken sidecar never leaves the phone holding the only copy. Tests: anUnreadableSidecarHoldsBackOnlyItsOwnFile, aRecordingWithAnUnreadableSidecarStillUploadsAfterADay.
| /** The unique name a legacy segment migrates to; null when [base] is not a legacy name. */ | ||
| fun migratedBase(base: String): String? { | ||
| val m = LEGACY.matchEntire(base) ?: return null | ||
| val id = UUID.nameUUIDFromBytes("legacy-segment:$base".toByteArray()) |
There was a problem hiding this comment.
Make migrated segment IDs unique across installations
When two phones have the same legacy base name—for example, each process records its first r1_s0 segment during the same UTC second—this deterministic UUID is identical because it contains no installation- or file-specific entropy. Since the edge uses the resulting name as storage identity, the first phone can store its bytes while the second receives a conflict and can never sync its different recording. Include a stable installation identifier or a persisted random migration ID while preserving interrupted-migration recovery.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Accepted as is: this app runs on a single phone per edge, and its legacy-named files have already been migrated. A cross-device collision needs two phones with legacy files from the same second on one edge, which this deployment cannot produce. The edge refuses a conflicting name rather than overwriting, so nothing is lost silently.
…ng or the queue A failure to schedule the sync job when a segment closes is logged instead of being read as an encoder failure that paused the local backup. An unreadable sidecar holds back only its own file. After a day that recording uploads without its metadata, so the phone never keeps the only copy because of a broken sidecar.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f2b768532
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL) == null) { | ||
| nm.createNotificationChannel( | ||
| NotificationChannel(CHANNEL, "Recording sync problems", NotificationManager.IMPORTANCE_HIGH), |
There was a problem hiding this comment.
Surface a disabled recording-sync notification channel
When a user disables the new “Recording sync problems” channel but leaves the existing capture_delivery_failure channel enabled, Android suppresses every day-old backlog alert while MainActivity keeps the notifications-off banner hidden because it checks only MicStreamService.FAILURE_CHANNEL_ID. Include this channel in that check or reuse the monitored failure channel so users are warned that sync alerts cannot appear.
Useful? React with 👍 / 👎.
| val p = getSharedPreferences(MicStreamService.PREFS, MODE_PRIVATE) | ||
| val candidates = listOf( | ||
| p.getString(MicStreamService.KEY_URL, "").orEmpty() to p.getString(MicStreamService.KEY_TOKEN, "").orEmpty(), | ||
| b.wsUrl.text.toString() to b.token.text.toString(), |
There was a problem hiding this comment.
Persist the endpoint needed to route an upgrade backlog
For a user who entered a custom endpoint in the UI and stopped recording normally before upgrading, these preferences cannot supply the advertised “last capture” settings: MicStreamService.stopCapture() clears capture_creds, and the activity initializes its fields from BuildConfig rather than the previous UI values. Consequently legacy recordings without .dest files either remain unroutable indefinitely or become bound to a different valid build-time endpoint; persist the last successful endpoint separately from the sticky-restart credentials and use that for migration.
Useful? React with 👍 / 👎.
# Conflicts: # clients/android-mic/app/build.gradle.kts # clients/android-mic/app/src/main/java/com/genesis/meetingmic/LocalRecorder.kt # clients/android-mic/app/src/main/java/com/genesis/meetingmic/MainActivity.kt # clients/android-mic/app/src/main/java/com/genesis/meetingmic/MicStreamService.kt
What
Each recording is now kept in two places. The phone keeps its local copy (#55), and this change mirrors every closed segment to the edge (endpoint from #56).
X.aacis waiting to sync;X.synced.aachas been confirmed by the edge. No other state can drift from the files./haveafter 7 days and deleted on a yes. The follow-up feat(meeting): keep every recording on the phone for 90 days #58 replaces this: the phone keeps every copy for 90 days.ep:<id>, where the id is a hash of the bridge address), and every segment records it in anX.destfile.ws://has no secure destination. It stays on the phone and is reported.SyncWorker(WorkManager).Verification
./gradlew testDebugUnitTestpasses 90/90.MockWebServertests against a fake edge:E2E: after the APK built from main (with #58) is installed, record a short meeting with an offline stretch. Every segment reaches the edge with a matching sha256, the app shows all synced, and the phone keeps its copies.
Shape: about 900 changed lines on the phone side, about 40% tests. One concern: the phone-to-edge mirror and the lifecycle of the phone copy.