iOS Background Frame Upload — Implementation Plan
Status: draft (awaiting approval) Type: feature Branch: feature/ios-bg-frame-upload
Caveats raised by orchestrator before execution begins: 1. Drafted Swift in Task 1 uses
@objc+RCTPromiseResolveBlock(legacy RN bridge). Project uses Expo Modules (expo.modules.wearablesmodule). Rework toFunction/AsyncFunctionfromExpoModulesCoreduring execution. 2. Bridge contract:enqueueBackgroundUploadshould resolve on enqueue success, not on upload completion. URLSession background uploads run while app is suspended/terminated — there is no JS context to resolve into when they finish. Persistent dedup index is the source of truth, updated by URLSession delegate writing to disk directly (not bridging back to JS). 3.ios/encache/Info.plistpath is unverified — confirm actual app target name during execution. 4.session_endmarker concept introduced here is new. Verify no existing marker convention before adding. 5. Presigned URL TTL must be confirmed ≥ 24h before merge; current value not yet read from server code.
System Intent
Problem: iOS suspends JS runtime ~30s after app backgrounds. setInterval polling in main/app/lib/capture-session.ts:94 (startFramePolling) stops firing. Glasses keep saving frames to disk via WearablesModule.swift. Upload queue stalls. Frames pile up. Foreground triggers a catch-up burst; if the app is force-killed or reboots before that, frames are orphaned on disk with no recovery path.
Goal: Frames upload to S3 in roughly real time when the app is "on" (foreground or alive); frames captured while the app is "off" (backgrounded, suspended, killed, rebooted) upload deterministically without loss or duplication.
Approach (Tier C, locked in): 1. iOS: Native URLSession with URLSessionConfiguration.background(withIdentifier:) drives uploads. JS hands (filePath, presignedURL, headers) to WearablesModule; OS owns the upload through suspension/termination/reboot. No JS bridge callback on completion — URLSession delegate writes to disk-persisted dedup index. 2. Android: Existing RecordingControlService foreground service already keeps JS alive while backgrounded. Existing setInterval polling continues to work. NO Android native changes required for the live path. 3. Cross-platform safety net: Cold-start disk-recovery sweep on app launch. Scans ${FileSystem.documentDirectory}/recordings/, finds sessions with frames + no terminal marker, enqueues orphans, closes sessions. Persistent on-disk dedup index per session.
Out of scope: - AppState foreground-flush as primary mechanism (URLSession bg makes it unnecessary on iOS; Android already covered by foreground service). - expo-background-fetch / expo-task-manager (15-min OS minimum; not needed since app-on = real-time already). - Silent push (chicken/egg). - Server-side changes beyond confirming presigned URL TTL.
Mermaid Diagram
graph TB
subgraph Capture["Capture (both platforms)"]
Glasses["Glasses BLE"] --> NativeWrite["Native module writes JPEG to disk"]
NativeWrite --> Disk["recordings/{sessionId}/frame-NNN.jpg"]
end
subgraph iOS["iOS upload path"]
Disk --> JSHandoff_iOS["JS: enqueueBackgroundUpload(path, url, headers)"]
JSHandoff_iOS --> URLSession["URLSession background config"]
URLSession --> S3_iOS["S3 (OS-driven, survives suspend/kill/reboot)"]
URLSession --> Delegate["URLSessionDelegate"]
Delegate --> DedupIndex["uploaded_frames.json on disk"]
end
subgraph Android["Android upload path"]
Disk --> Polling["Existing setInterval polling"]
Polling --> FetchPUT["fetch PUT presignedURL"]
FetchPUT --> S3_Android["S3"]
FetchPUT --> DedupIndex
FGService["RecordingControlService foreground service"] -.keeps alive.-> Polling
end
subgraph Recovery["Cold-start recovery (both platforms)"]
AppLaunch["App launch"] --> Sweep["recoverOrphanedSessions()"]
Sweep --> ScanDirs["Scan recordings/* for orphans"]
ScanDirs --> CheckMarker["Has session_end marker?"]
CheckMarker -->|no| EnqueueOrphans["Enqueue unuploaded frames"]
CheckMarker -->|yes| Skip["Skip"]
EnqueueOrphans --> URLSession
EnqueueOrphans --> FetchPUT
EnqueueOrphans --> CloseSession["Close session via API"]
end
style URLSession fill:#f3e5f5
style DedupIndex fill:#fff3e0
style FGService fill:#e3f2fd Black-Box Input/Output Contracts
Native: WearablesModule.enqueueBackgroundUpload(filePath, presignedURL, headers)
Input: - filePath: string — absolute path to JPEG on disk - presignedURL: string — S3 PUT URL (TTL ≥ 24h) - headers: Record<string, string> — must include Content-Type: image/jpeg
Output Success: { taskId: number, status: "enqueued" } resolved synchronously after URLSessionUploadTask.resume(). Does NOT wait for upload completion.
Output Failure: rejects with { code, message } if file missing, URL invalid, or session config errors.
Side effect on completion (async, may happen after app suspension/relaunch): URLSession delegate appends filename to ${sessionDir}/uploaded_frames.json.
Flow: Real-Time Upload (iOS, Foreground or Background)
Input: Glasses produces frame; native writes to recordings/{sessionId}/frame-NNN.jpg.
Output: JS detects frame, fetches presigned URL, calls native enqueue. URLSession uploads to S3 within seconds (or upon next OS-allowed window if discretionary). Dedup index updated by delegate.
Failure modes: - Network unavailable: URLSession retries automatically per its config. - App killed (crash or OS memory pressure): OS resumes upload independently. App relaunches eventually to deliver completion via application(_:handleEventsForBackgroundURLSession:). - User force-quit: iOS cancels all background URLSession transfers when the user explicitly force-quits the app (double-tap Home, swipe away). Frames captured during that session are not lost — cold-start recovery re-enqueues them on next launch — but in-flight transfers do not complete.
Test mapping: main/app/__tests__/frame-recovery.integration.test.ts
Flow: Cold-Start Recovery
Input: App launches. recoverOrphanedSessions() runs.
Output Success: - Each orphaned session (frames + no session_end marker) has all unuploaded frames enqueued. - Server /sessions/{id}/end called for each. - session_end marker written locally.
Failure modes: - Frame file deleted between disk scan and enqueue: skip, mark in dedup index. - Server unreachable for closeSession: continue, retry on next launch (do NOT write session_end marker yet).
Test mapping: main/app/__tests__/frame-recovery.test.ts
Flow: Session End
Input: User stops recording or session timeout fires.
Output Success: - closeSession(sessionId) POSTed to server. - session_end marker written to recordings/{sessionId}/session_end.
Failure modes: - Server unreachable: do NOT write session_end marker. Cold-start recovery will retry.
Test mapping: main/app/__tests__/session-lifecycle.test.ts
Files Affected
| File | Change | Reason |
|---|---|---|
main/app/wearables-module/ios/WearablesModule.swift | Modify | Add URLSession bg config + enqueueBackgroundUpload Expo Function + delegate that updates dedup index |
main/app/wearables-module/src/WearablesModule.ts | Modify | TS declarations for new native function |
main/app/lib/capture-session.ts | Modify | Branch upload by Platform.OS; call recovery on launch; write session_end marker on stop |
main/app/lib/frame-recovery.ts | Create | recoverOrphanedSessions() and getDedupIndex() / markUploaded() helpers |
main/app/__tests__/frame-recovery.test.ts | Create | Cold-start recovery scenarios |
main/app/__tests__/frame-recovery.integration.test.ts | Create | iOS native bridge contract |
main/app/__tests__/session-lifecycle.test.ts | Create | session_end marker behavior |
main/app/__tests__/capture-session.test.ts | Modify | Mock WearablesModule for iOS path |
main/app/wearables-module/expo-module.config.json | No change | URLSession background transfers do NOT require any UIBackgroundModes entry. Earlier draft of this plan incorrectly listed fetch and processing here; corrected. |
main/app/ios/<target>/Info.plist | No change | Same — no UIBackgroundModes entry needed for URLSession bg uploads. Existing modes (e.g. audio for capture) are untouched. |
docs/docs/capture-session.md | Modify | Document iOS bg + Android FG service + cold-start sweep |
docs/docs/upload-frame.md | Modify | Document native enqueue path + presigned URL TTL requirement |
Implementation Tasks
Task 1: Native — URLSession Background Config + Expo Function
Files: - Modify: main/app/wearables-module/ios/WearablesModule.swift
Steps: - [ ] Read existing module to understand Expo Module style (Function / AsyncFunction / definition { ... }). - [ ] Add lazy URLSession with URLSessionConfiguration.background(withIdentifier: "com.encache.frame-uploads"). isDiscretionary = false. shouldUseExtendedBackgroundIdleMode = true. - [ ] Add delegate (URLSessionTaskDelegate) that writes to ${sessionDir}/uploaded_frames.json on didCompleteWithError == nil. Path derived from upload task originalRequest.url query param sessionId OR from a per-task-id mapping persisted to disk. - [ ] Add Expo AsyncFunction("enqueueBackgroundUpload") { (filePath: String, presignedURL: String, headers: [String: String]) -> [String: Any] in ... } that creates URLRequest, uploadTask(with:fromFile:), calls resume(), returns [taskId, status: "enqueued"]. - [ ] Wire application(_:handleEventsForBackgroundURLSession:completionHandler:) in AppDelegate / Expo lifecycle to allow URLSession to deliver completion events when app is relaunched in bg. - [ ] Build the iOS app once locally (npx expo run:ios or equivalent) to confirm compilation before moving on.
Task 2: TypeScript Declarations
Files: - Modify: main/app/wearables-module/src/WearablesModule.ts
Steps: - [ ] Add enqueueBackgroundUpload(filePath: string, presignedURL: string, headers: Record<string, string>): Promise<{ taskId: number; status: "enqueued" }> to module type.
Task 3: Frame Recovery Module (Tests First)
Files: - Create: main/app/__tests__/frame-recovery.test.ts - Create: main/app/lib/frame-recovery.ts
Steps: - [ ] Write failing tests: - recoverOrphanedSessions() finds session with frames + no session_end, uploads them via the platform-specific path, calls closeSession, writes session_end marker. - Frames already in uploaded_frames.json are skipped. - Session with session_end marker is skipped entirely. - closeSession failure does NOT write session_end marker (so retry happens on next launch). - [ ] Run tests, confirm fail. - [ ] Implement frame-recovery.ts exporting recoverOrphanedSessions, getDedupIndex(sessionId), markUploaded(sessionId, filename). - [ ] Run tests, confirm pass.
Task 4: Capture-Session Integration
Files: - Modify: main/app/lib/capture-session.ts - Modify: main/app/__tests__/capture-session.test.ts
Steps: - [ ] Import Platform, WearablesModule, recoverOrphanedSessions, getDedupIndex, markUploaded. - [ ] In session bootstrap, run recoverOrphanedSessions() once on first call (idempotent guard via module-level flag). - [ ] In upload path: branch on Platform.OS === 'ios' → WearablesModule.enqueueBackgroundUpload(...); else → existing fetch path. Both update dedup index after success (Android via JS, iOS via native delegate; JS also mirrors for visibility). - [ ] On session end: write session_end marker only after closeSession succeeds. - [ ] Update tests to mock WearablesModule and frame-recovery.
Task 5: Manifest / Entitlements
Files: - Modify: main/app/wearables-module/expo-module.config.json - Verify/Modify: iOS Info.plist (correct path TBD)
Steps: - [ ] URLSession background transfers do NOT require any UIBackgroundModes entry — the OS background delivery is built into the URLSession background configuration. fetch is for BGAppRefresh; processing is for BGProcessingTask. Neither is needed here. - [ ] If other features already add UIBackgroundModes values (e.g. audio), leave them intact — do not remove. - [ ] If Info.plist already has UIBackgroundModes array, merge — do not replace existing modes (audio, etc. may be present).
Task 6: Documentation
Files: - Modify: docs/docs/capture-session.md - Modify: docs/docs/upload-frame.md
Steps: - [ ] Document iOS URLSession bg path, Android foreground-service path, cold-start recovery, dedup index, session_end marker. - [ ] Document presigned URL TTL requirement (≥24h). - [ ] Cross-reference native module file.
Task 7: Presigned URL TTL Verification
Files: - Read-only investigation across main/server/ to find current TTL.
Steps: - [ ] Grep main/server for Expires, expires_in, presign_url, S3 client usage. - [ ] If TTL < 24h, add a follow-up task (do NOT change server in this PR scope). - [ ] Document actual current TTL in docs/docs/upload-frame.md.
Acceptance Criteria
- [ ] iOS: frames captured while app is backgrounded, suspended, terminated, or after device reboot upload to S3 within seconds of network availability.
- [ ] Android: existing real-time upload path while backgrounded preserved (no regression to
RecordingControlServiceor JS polling). - [ ] On app launch, any orphaned session (frames on disk, no
session_endmarker) is recovered: frames uploaded, session closed, marker written. - [ ] No duplicate uploads. Dedup persisted to disk per-session, not in-memory only.
- [ ] No regression to current foreground polling, session lifecycle, or the existing
/sessions/{id}/frames+/sessions/{id}/endendpoints. - [ ] All new tests pass; existing
capture-session.test.tscontinues to pass. - [ ]
npx expo run:iosbuilds cleanly with new entitlements.
Open Questions
- Does
WearablesModule.swiftalready use Expo Modules style (definition)? Confirm during Task 1. - What is the actual iOS app target name (for
Info.plistpath)? - Where is
closeSessiondefined inmain/app/lib/? Need to confirm it exists or add it. - Is there a frame retry/backoff today in
uploadQueue? URLSession bg has its own retry — do not double-retry. - Server presigned URL TTL — TBD (Task 7).