Skip to content

feat(meeting): mirror every recording to the edge, delete only on proof - #57

Merged
WingedGuardian merged 16 commits into
mainfrom
feat/meeting-mic-sync
Oct 23, 2026
Merged

WingedGuardian merged 16 commits into
mainfrom
feat/meeting-mic-sync

Conversation

@WingedGuardian

@WingedGuardian WingedGuardian commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

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).

  • The folder is the queue. X.aac is waiting to sync; X.synced.aac has been confirmed by the edge. No other state can drift from the files.
    • Every closed segment is uploaded, including the short final one written at Stop.
    • A segment counts as synced only when the edge's re-read sha256 and length match the phone's, and the rename has actually happened.
    • A file the edge answers for but can't take right now waits 10 minutes without blocking the others. No answer at all skips that edge for the rest of the pass.
    • An unreadable sidecar holds back only its own file. After a day, that file uploads without its metadata.
    • In this PR, a confirmed copy is re-checked with /have after 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.
  • Each recording goes only to the bridge it was captured for.
    • Each capture run registers its endpoint (ep:<id>, where the id is a hash of the bridge address), and every segment records it in an X.dest file.
    • A new token for the same bridge carries waiting recordings over, and clears their refusal delay.
    • A recording made over plain ws:// has no secure destination. It stays on the phone and is reported.
    • Recordings from earlier builds take the endpoint those builds used, once, and only if they were made before the upgrade.
    • One unreachable bridge does not hold back uploads to another.
  • Identity. Segment names carry a per-run UUID. Files named under the old scheme are migrated once, audio first, then sidecar; an interrupted migration finishes on the next run.
  • SyncWorker (WorkManager).
    • A sync job is started whenever a file closes or the app opens. A job already waiting to retry is replaced, so a recovered network is used at once.
    • A catch-up job runs every hour.
    • A separate check every 3 h with no network requirement raises a loud alert when a recording has waited over a day, or has no secure destination. The alert clears once nothing is stuck.
    • A failure to schedule the job is logged and never stops recording.
  • Status. The headline never shows success while some recordings can't sync. The screen also lists waiting, confirmed, refused, expired and no-destination counts.
  • Version 0.6.0 (6). feat(meeting): keep every recording on the phone for 90 days #58 bumps it to 0.6.1.

Verification

  • ./gradlew testDebugUnitTest passes 90/90.
    • Decision tests.
    • MockWebServer tests against a fake edge:
      • receipts and the re-check;
      • per-bridge routing;
      • no-destination files;
      • an unreachable old bridge;
      • legacy adoption and the cutoff;
      • token rotation;
      • the refusal-delay reset;
      • an unreadable sidecar.
  • Verify-RED: every rule added in review fails its test when mutated (16 mutants across rounds, all killed).
  • Reviews: 4 review rounds (Devin, then Codex three times). Every finding was fixed or answered on its thread. Fresh-context architect audits ran at each fix commit.
  • Device: against a deployed edge running feat(meeting): phone segment upload with a receipt that cannot lie #56, legacy recordings migrated and synced, and decoded cleanly on the edge. That was on an earlier head; the final head ships together with feat(meeting): keep every recording on the phone for 90 days #58 in one APK.

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.

…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).
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 915b8929-90bd-4531-ab49-dcbab930123a
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-23T18:21:48.468450Z 1f54d2d New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

Devin Review

Comment on lines +212 to +216
val text = try {
LocalRecorder.summary(app) + "\n" + syncLine(SegmentSync.status(app))
} catch (e: Exception) {
"On this phone: could not read recordings"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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 29883d8: the sync line refreshes whenever a sync job (the kick or the hourly one) changes state, while the screen is open.

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.

Already addressed: MainActivity observes both sync jobs via SyncWorker.observe and refreshes the storage summary on every state change.

Comment on lines +241 to +243
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"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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 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://")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 Plaintext sync exposes recordings and token

With a ws:// endpoint, httpBase uploads full recordings and the path token over HTTP. Anyone observing that network can read the recordings and token.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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 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
@WingedGuardian

Copy link
Copy Markdown
Owner Author

Handoff to a closing session (state 2026-10-22):

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +109 to +111
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()

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 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 👍 / 👎.

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 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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 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.

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.

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.

@WingedGuardian

Copy link
Copy Markdown
Owner Author

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.
@WingedGuardian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

result == Closed.IO -> monitor.setLastCheck(generation, "last file may not be fully saved: storage error")
result == Closed.ENCODER -> monitor.setLastCheck(generation, "last file is missing its final audio: encoder did not finish")

P2 Badge Kick sync for nonempty segments closed with errors

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".

Comment on lines +286 to +287
val rejectedAt = state.getLong("rejected:" + f.name)
if (rejectedAt > 0 && now() - rejectedAt < REJECT_RETRY_MS) continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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 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.

Comment on lines +225 to +226
s.waiting == 0 && s.synced == 0 -> "Synced to Genesis: nothing waiting"
s.waiting == 0 -> "Synced to Genesis: all recordings ✓"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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 de06bbf: the headline shows a warning, never a success, while unroutable > 0 (syncLine is now a testable top-level function). Test: theHeadlineIsNeverASuccessWhileSomeRecordingsCannotSync.

Comment on lines +39 to +40
} else {
warnIfStuck(applicationContext)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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 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.
@WingedGuardian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

Not changing here: the timestamp only drives the 7-day delete, which the follow-up #58 removes (the phone keeps every copy 90 days). Under #58, a failed timestamp update can only make the one-time re-check run early, which is harmless.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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 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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +121 to +124
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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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
@WingedGuardian
WingedGuardian changed the base branch from feat/meeting-mic-local-backup to main October 23, 2026 18:21
@WingedGuardian
WingedGuardian merged commit ba183e5 into main Oct 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant