Upload Frame
The client uploads individual JPEG frames to the server during a session. The upload path depends on the platform:
- iOS: Uses
URLSessionConfiguration.background()to upload directly to S3 via presigned URLs. Uploads persist through app suspension, termination, and reboot. - Android: Uses fetch API to POST frame bytes to the server (existing path).
The server Lambda maintains a rolling frame counter, writes each frame to S3 inside its window directory, and triggers ingest when 60 frames (one full window) have been received and the matching audio chunk is also ready.
iOS Background Upload Path
Presigned URL Generation
When the iOS app enqueues a frame for background upload, it first requests a presigned URL:
GET /sessions/{sessionId}/frames/presigned-url?filePath={encodeURIComponent(filepath)}
Response: { "presignedURL": "https://s3.amazonaws.com/bucket/..." }
The presigned URL is a PUT endpoint (not POST) that allows direct S3 uploads without authentication.
IMPORTANT: Current TTL is 3600 seconds (1 hour). This is insufficient for the design intent: - Network unavailable when enqueue is called → upload queued on disk - App backgrounded/suspended for hours → TTL expires before upload resumes - Device rebooted → TTL may expire before OS allows upload - App killed and relaunched days later (cold-start recovery) → presigned URL has expired
This is a known limitation. A follow-up issue should increase the TTL to >= 86400 seconds (24 hours). See: https://github.com/EncacheAI/encache/issues (TBD after merge).
Native Enqueue Contract
Once the presigned URL is obtained, the iOS app calls:
// filePath: plain filesystem path (no file:// prefix)
// presignedURL: S3 presigned PUT URL (TTL >= 24h required)
// headers: must include Content-Type
const result = await WearablesModule.enqueueBackgroundUpload(
"/path/to/recordings/sessionId/frame-001.jpg",
"https://s3.amazonaws.com/...",
{ "Content-Type": "image/jpeg" },
);
// result: { taskId: number, status: "enqueued" }
This is an Expo native function that: - Creates a URLSessionUploadTask with the presigned URL - Calls resume() to start the background upload - Returns immediately (does NOT wait for completion) - The OS manages retries and will resume uploads even if the app is terminated
Dedup Index
The native delegate writes completed uploads to a disk-persisted dedup index:
This is an array of filenames that have been successfully uploaded. On cold-start recovery, unuploaded frames are re-enqueued.
Android / Server Path (POST)
Flow
-
Client sends frame — A
POSTrequest is sent to/sessions/{sessionId}/frameswith a JPEG image as the body (possibly base64-encoded by API Gateway). -
Session validation —
sessionIdis extracted from path parameters; missing returns HTTP 400. -
Atomic frame counter increment — DynamoDB
frameCountis incremented by 1 usingUpdateExpression SET frameCount = frameCount + :onewithReturnValues=ALL_NEW. The currentwindowIndexanduserIdare read from the returned attributes. -
Frame storage — The frame is written to S3 at
sessions/{sessionId}/window_{windowIndex:03d}/frame_{frameNum:03d}.jpg(0-indexed within the window). -
Window completion check — If
frameCount >= FRAMES_PER_WINDOW(60): - DynamoDB is atomically updated:
frameCountresets to 0,currentFrameWindowincrements by 1, andwindowIndexis added tocompletedFrameWindows. -
Ingest is triggered if
captureMode == "audio_only"ORwindowIndexalready appears incompletedAudioWindows. -
Ingest deduplication — Same conditional
ADDtoingestTriggeredWindowsas the audio upload flow, preventing duplicate ingest invocations. -
Response — Returns
{ "frameCount": n, "windowIndex": n, "ingestTriggered": true/false }.
Entry Point
- Lambda:
main/server/api/sessions/frames/app.py→lambda_handler - HTTP method:
POST /sessions/{sessionId}/frames(API Gateway)
Key Constants
FRAMES_PER_WINDOW = 60— number of frames that complete one window
Dependencies
- S3 bucket:
BUCKET_NAME - DynamoDB:
SESSIONS_TABLE_NAME - Lambda:
INGEST_FUNCTION_NAME
Error Cases
| Condition | Response |
|---|---|
| Missing sessionId path param | 400 |