Skip to content

Capture Session

Metadata

  • System type: library

System Intent

  • What this is: The client-side orchestrator for a recording session. It opens a server session, selects the correct capture path based on the active recording device (glasses, glasses-audio, or phone), routes audio/frame chunks into a PersistentUploadQueue, and tears everything down cleanly when the user stops recording. All three device paths share the same session lifecycle: POST /sessions/start → capture → flush queue → POST /sessions/{id}/end.

Upload Paths (Platform-Specific)

iOS: URLSession Background Upload

On iOS, frames are uploaded using URLSessionConfiguration.background(withIdentifier: "com.encache.frame-uploads"). This allows uploads to continue even while the app is suspended, backgrounded, or terminated. The upload lifecycle:

  1. Enqueue Phase (in uploadItem when Platform.OS === "ios"):
  2. Fetch presigned URL from server: GET /sessions/{sessionId}/frames/presigned-url?filePath=...
  3. Strip file:// URI prefix from path; pass plain filesystem path to native
  4. Call WearablesModule.enqueueBackgroundUpload(filePath, presignedURL, headers) (Expo native function)
  5. Function returns immediately with {taskId, status: "enqueued"} (does NOT wait for upload completion)
  6. Do not mark the frame uploaded at enqueue time — only the native delegate marks it after confirmed completion

  7. Background Upload Phase (OS-managed):

  8. URLSession uploads to S3 using the presigned URL
  9. OS retries automatically if network is unavailable
  10. OS resumes uploads even if app is terminated

  11. Completion Phase (URLSessionDelegate callback):

  12. Native delegate in WearablesModule handles urlSession(_:task:didCompleteWithError:) callback
  13. Writes filename to ${FileSystem.documentDirectory}/recordings/{sessionId}/uploaded_frames.json on disk
  14. This dedup index is the source of truth for uploaded frames

Android: Existing Polling Path

On Android, the existing setInterval polling in startFramePolling continues to work:

  1. Polling interval: 500ms (checks for new frames on disk)
  2. Upload path: POST /sessions/{sessionId}/frames with frame bytes (fetch API)
  3. RecordingControlService foreground service keeps JS alive while backgrounded
  4. Dedup index also updated via markUploaded(sessionId, filename)

Cold-Start Recovery (Both Platforms)

On app launch, recoverOrphanedSessions() is called once via startCapture():

  1. Scans ${FileSystem.documentDirectory}/recordings/ for session directories
  2. For each session:
  3. Check if session_end marker file exists (if yes, skip)
  4. Read dedup index (uploaded_frames.json)
  5. Find unuploaded frames (frames on disk not in dedup index)
  6. Enqueue unuploaded frames via platform-specific path
  7. Close orphaned sessions: POST /sessions/{sessionId}/end
  8. Write session_end marker only after server close succeeds (prevents retry on failure)

Session End Marker

The session_end marker file (${FileSystem.documentDirectory}/recordings/{sessionId}/session_end) signals completion. It is written ONLY after closeSession API call succeeds. If the API call fails, the marker is NOT written, and cold-start recovery will retry on the next app launch.

Mermaid Diagram

flowchart TD
  Tap([User — tap record]) --> Orchestrator[recording-control-orchestrator]
  Orchestrator -->|startCapture| CS[capture-session.ts]

  CS --> DeviceBranch{activeRecordingDevice}

  DeviceBranch -->|glasses or glasses-audio + SDK available| GlassesPath[Wearables SDK path]
  GlassesPath --> AudioListener[startAudioChunkListener\nonAudioChunkReady → enqueue audio]
  GlassesPath --> WearablesAudio[WearablesModule.startAudioCapture]
  GlassesPath -->|glasses only| WearablesFrames[startStreamSession\nstartRecordingFrames\nstartFramePolling every 500ms]

  DeviceBranch -->|phone or SDK unavailable| PhonePath[Phone path]
  PhonePath --> PhoneAudio[startPhoneAudioCapture\nonChunkReady → enqueue audio]

  AudioListener & PhoneAudio --> Queue[PersistentUploadQueue\nenqueue audio/wav windowIndex=n\nenqueue frame/jpeg]
  Queue -->|uploadItem| API[POST /sessions/id/audio\nPOST /sessions/id/frames]

  StopTap([User — tap stop]) --> Orchestrator
  Orchestrator -->|stopCapture| CS

  CS --> StopBranch{startedWithPhone?}
  StopBranch -->|yes| StopPhone[stopPhoneAudioCapture\nflush queue]
  StopBranch -->|no — wearables| StopWearables[stopAudioTeardown\nstopFramesTeardown\nflush queue]
  StopPhone & StopWearables --> EndSession[POST /sessions/id/end]

Flows

Flow: startCapture

  • Test files: main/app/__tests__/capture-session.test.ts
  • Core files: main/app/lib/capture-session.ts

Types

RecordingDevice {
  value: "glasses" | "glasses-audio" | "phone"
}

StartCaptureInput {
  void   (reads selectedDevice from recording-device-preference at call time)
}

StartCaptureOutput {
  void   (server session created, upload queue running, capture active)
}

Paths

path input output path-type notes
startCapture.glasses.success selectedDevice=glasses, SDK available void; session created with captureMode=audio_video; audio listener + frame polling active happy path startAudioChunkListener → WearablesModule events; startFramePolling polls every 500 ms
startCapture.glasses-audio.success selectedDevice=glasses-audio, SDK available void; session created with captureMode=audio_only; audio listener active; no frame polling happy path mirrors glasses path minus startStreamSession / startRecordingFrames
startCapture.phone.success selectedDevice=phone (or SDK unavailable) void; session created with captureMode=audio_only; startPhoneAudioCapture active happy path phone path triggers when device is "phone" or when wearables SDK is unavailable for a glasses device
startCapture.phone.permission-denied selectedDevice=phone, mic permission denied throws; session cleaned up via cleanupSession error startPhoneAudioCapture throws; caught in try/catch; cleanupSession ends server session before re-throwing
startCapture.already-in-progress activeRecordingDevice !== null throws Error("Capture already in progress") error guard; no server call made

Pseudocode

if activeRecordingDevice !== null → throw "Capture already in progress"
activeRecordingDevice = getSelectedRecordingDevice()
useWearablesCapture = (device is glasses/glasses-audio) AND isSdkAvailable()
captureMode = useWearablesCapture
  ? (device === "glasses-audio" ? "audio_only" : "audio_video")
  : "audio_only"   // phone always audio only

{ sessionId } = await POST /sessions/start { captureMode }
uploadQueue = new PersistentUploadQueue({ sessionId, uploadFn: uploadItem })

try:
  if useWearablesCapture:
    startAudioChunkListener()              // WearablesModule.onAudioChunkReady → enqueue
    await WearablesModule.startAudioCapture()
    if device !== "glasses-audio":
      await WearablesModule.startStreamSession()
      recordingDir = await WearablesModule.startRecordingFrames()
      if recordingDir: startFramePolling(recordingDir)
  else:
    await startPhoneAudioCapture(event => uploadQueue.enqueue("audio", ...))
catch:
  await cleanupSession()
  throw

Flow: stopCapture

  • Test files: main/app/__tests__/capture-session.test.ts
  • Core files: main/app/lib/capture-session.ts

Types

StopCaptureInput {
  void   (uses activeRecordingDevice captured at startCapture time)
}

StopCaptureOutput {
  void   (all audio stopped, queue flushed, session ended on server)
}

Paths

path input output path-type notes
stopCapture.phone.success activeRecordingDevice=phone void; phone audio stopped; final chunk enqueued; queue flushed; /sessions/{id}/end called happy path stopAudioChunkListener (no-op for phone); stopPhoneAudioCapture; uploadQueue.flush(); then end session
stopCapture.phone.stop-error activeRecordingDevice=phone + stopPhoneAudioCapture throws error logged; queue still flushed; session still ended error errors caught and logged, not re-thrown
stopCapture.glasses.success activeRecordingDevice=glasses void; frame polling stopped; audio stopped; final frames collected; queue flushed; session ended happy path stopFramePollingstopAudioTeardownstopFramesTeardown (reads final frames dir) → flush → end
stopCapture.glasses-audio.success activeRecordingDevice=glasses-audio void; audio stopped; queue flushed; session ended happy path no frame teardown

Pseudocode

startedWithPhone = activeRecordingDevice === "phone"
startedWithWearables = device is glasses or glasses-audio
sdkAvailable = isSdkAvailable()

if startedWithPhone:
  stopAudioChunkListener()
  try: await stopPhoneAudioCapture()
  catch: log capture_phone_audio_stop_error
  if uploadQueue: await uploadQueue.flush()
else if startedWithWearables:
  stopFramePolling()
  await stopAudioTeardown(sdkAvailable)      // WearablesModule.stopAudioCapture + stopAudioChunkListener
  if device !== "glasses-audio":
    await stopFramesTeardown(sdkAvailable)   // stopRecordingFrames + enqueue stragglers + stopStreamSession
  if uploadQueue: await uploadQueue.flush()

if currentSessionId: await POST /sessions/{currentSessionId}/end

activeRecordingDevice = null; currentSessionId = null
await uploadQueue?.destroy(); uploadQueue = null

Flow: cleanupSession (error recovery)

  • Core files: main/app/lib/capture-session.ts

Paths

path input output path-type notes
cleanupSession.phone activeRecordingDevice=phone phone audio stopped; server session ended; queue destroyed happy path used when startCapture fails mid-way after a session was already created
cleanupSession.wearables activeRecordingDevice=glasses or glasses-audio wearables audio + stream stopped; server session ended; queue destroyed happy path calls WearablesModule.stopAudioCapture + conditionally stopStreamSession

Pseudocode

stopFramePolling(); stopAudioChunkListener()
if activeRecordingDevice === "phone":
  try: await stopPhoneAudioCapture()
  catch: log cleanup_stop_phone_audio_error
else:
  try: await WearablesModule.stopAudioCapture()
  catch: log cleanup_stop_audio_error
  if activeRecordingDevice !== "glasses-audio":
    try: await WearablesModule.stopStreamSession()
    catch: log cleanup_stop_stream_error

if currentSessionId: await POST /sessions/{currentSessionId}/end
activeRecordingDevice = null; currentSessionId = null
await uploadQueue?.destroy(); uploadQueue = null

Flow: uploadItem (internal)

  • Core files: main/app/lib/capture-session.ts

Types

UploadItem {
  type: "frame" | "audio"
  uri: string          (file:// URI)
  sessionId: string
  windowIndex?: number (audio only)
  sizeBytes: number
}

Paths

path input output path-type notes
uploadItem.audio type=audio, windowIndex=n POST /sessions/{id}/audio?windowIndex=n with audio/wav body happy path 30-second timeout; bytes read via expo-file-system File.bytes()
uploadItem.frame type=frame POST /sessions/{id}/frames with image/jpeg body happy path 10-second timeout

Logs

Source Location
All steps createFlowLogger("capture") — step keys include: capture_start, session_created, capture_phone_mode_starting, capture_phone_mode_started, capture_audio_starting, capture_audio_started, capture_phone_audio_stopped, capture_phone_audio_stop_error, capture_flush_queue, capture_flush_queue_error, session_ended, session_end_error, frame_poll_error, frame_final_poll_error, cleanup_stop_phone_audio_error, cleanup_stop_audio_error, cleanup_stop_stream_error, session_cleanup_ended, session_cleanup_error

Deployment

  • Mechanism: local only (shipped as part of the React Native app bundle)
  • Deploy command:
    # iOS
    npx expo run:ios
    
    # Android
    npx expo run:android
    
  • Notes: The phone path requires expo-av (npx expo install expo-av). The activeRecordingDevice value is captured at startCapture time and is used throughout teardown — changing the user's device preference mid-session has no effect on the current session's teardown path.