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, orphone), routes audio/frame chunks into aPersistentUploadQueue, 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:
- Enqueue Phase (in
uploadItemwhenPlatform.OS === "ios"): - Fetch presigned URL from server:
GET /sessions/{sessionId}/frames/presigned-url?filePath=... - Strip
file://URI prefix from path; pass plain filesystem path to native - Call
WearablesModule.enqueueBackgroundUpload(filePath, presignedURL, headers)(Expo native function) - Function returns immediately with
{taskId, status: "enqueued"}(does NOT wait for upload completion) -
Do not mark the frame uploaded at enqueue time — only the native delegate marks it after confirmed completion
-
Background Upload Phase (OS-managed):
- URLSession uploads to S3 using the presigned URL
- OS retries automatically if network is unavailable
-
OS resumes uploads even if app is terminated
-
Completion Phase (URLSessionDelegate callback):
- Native delegate in
WearablesModulehandlesurlSession(_:task:didCompleteWithError:)callback - Writes filename to
${FileSystem.documentDirectory}/recordings/{sessionId}/uploaded_frames.jsonon disk - 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:
- Polling interval: 500ms (checks for new frames on disk)
- Upload path:
POST /sessions/{sessionId}/frameswith frame bytes (fetch API) - RecordingControlService foreground service keeps JS alive while backgrounded
- Dedup index also updated via
markUploaded(sessionId, filename)
Cold-Start Recovery (Both Platforms)
On app launch, recoverOrphanedSessions() is called once via startCapture():
- Scans
${FileSystem.documentDirectory}/recordings/for session directories - For each session:
- Check if
session_endmarker file exists (if yes, skip) - Read dedup index (
uploaded_frames.json) - Find unuploaded frames (frames on disk not in dedup index)
- Enqueue unuploaded frames via platform-specific path
- Close orphaned sessions:
POST /sessions/{sessionId}/end - Write
session_endmarker 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 | stopFramePolling → stopAudioTeardown → stopFramesTeardown (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:
- Notes: The phone path requires
expo-av(npx expo install expo-av). TheactiveRecordingDevicevalue is captured atstartCapturetime and is used throughout teardown — changing the user's device preference mid-session has no effect on the current session's teardown path.