Skip to content

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 to Function / AsyncFunction from ExpoModulesCore during execution. 2. Bridge contract: enqueueBackgroundUpload should 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.plist path is unverified — confirm actual app target name during execution. 4. session_end marker 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 RecordingControlService or JS polling).
  • [ ] On app launch, any orphaned session (frames on disk, no session_end marker) 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}/end endpoints.
  • [ ] All new tests pass; existing capture-session.test.ts continues to pass.
  • [ ] npx expo run:ios builds cleanly with new entitlements.

Open Questions

  1. Does WearablesModule.swift already use Expo Modules style (definition)? Confirm during Task 1.
  2. What is the actual iOS app target name (for Info.plist path)?
  3. Where is closeSession defined in main/app/lib/? Need to confirm it exists or add it.
  4. Is there a frame retry/backoff today in uploadQueue? URLSession bg has its own retry — do not double-retry.
  5. Server presigned URL TTL — TBD (Task 7).