From e3296216593c5fa809a32c0b111fa41d4a20ddbe Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:25:01 -0400 Subject: [PATCH 01/18] docs: media store phase 3 implementation plan --- .../plans/2026-07-10-media-store-phase3.md | 1225 +++++++++++++++++ 1 file changed, 1225 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-media-store-phase3.md diff --git a/docs/superpowers/plans/2026-07-10-media-store-phase3.md b/docs/superpowers/plans/2026-07-10-media-store-phase3.md new file mode 100644 index 000000000..d969b8b05 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-media-store-phase3.md @@ -0,0 +1,1225 @@ +# Media Store Phase 3 (Large Objects) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Videos upload through S3 multipart with per-part resume and progress, survive kill-and-resume, and play on a second device; gallery-video poster thumbs render everywhere — Phase 3 of the Media Store spec (`docs/superpowers/specs/2026-07-10-s3-media-storage-design.md`, sections 8, 9, 17). + +**Architecture:** The `MediaObjectStore` interface gains optional progress/resume parameters (the extension Phase 1 planned for). `S3ApiClient` gains the multipart operations (Create/UploadPart/Complete/Abort/ListParts) plus Range GET; `S3MediaObjectStore.putFile` becomes threshold-switched (single-shot under 8 MiB, multipart loop above, resume state persisted after every acknowledged part), and `getFile` streams Range chunks to disk so downloads stay memory-bounded. The pipeline lifts Phase 2's video-ineligibility gate, threads resume state and progress through the queue (local cache DB v3 adds progress columns), and video playback on remote devices rides a store fallback inside `resolvedFilePathProvider` — the single seam `photo_viewer_page`'s `_VideoItem` already consumes. + +**Tech Stack:** Existing media-store stack; no new dependencies. The chunk loop lives inside the S3 adapter for now — extracting a shared `TransferEngine` waits for Phase 4 when a second session protocol (Drive/Dropbox) exists to generalize over. + +## Global Constraints + +- Work ONLY in the worktree: `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/media-store-phase3` (branch `worktree-media-store-phase3`, stacked on `worktree-media-store-phase2`; the PR targets that branch). +- Main DB stays at v103. Local cache DB goes v2 -> v3 (two nullable progress columns; `m.addColumn` migration). +- TDD; per-file test runs with `set -o pipefail` when piping through `tail`; per-test `--timeout 60s` on new suites. +- `dart format .` + whole-project `flutter analyze` before every commit (CI is `--fatal-infos`). No emojis. Conventional single-line commit messages, no trailers. +- Every file must be Read at its phase-3 worktree absolute path before its first Edit (edit tracking resets per worktree). +- Widget tests render from snapshot data, never live drift `watch()` streams; provider tests hold `container.listen` subscriptions (Riverpod 3 auto-pause) and poll until the EXPECTED value appears (stale `.value` persists across invalidation). +- **Documented spec deviation:** non-gallery (localFile) video thumbnails are deferred to Phase 5. Gallery videos - the dominant path on iOS/Android/macOS - get poster thumbs free through photo_manager's `resolveThumbnail` (`BytesData`); localFile videos degrade to the placeholder, extending the spec's own accepted Windows/Linux degradation (spec section 19). No native thumbnailer dependency enters this phase. +- Phase 1+2 signatures consumed verbatim: `StoreKeys.objectKey/thumbKey/extensionFor/contentTypeFor` (mp4/mov already mapped), `sha256OfFile`, `MediaTransferQueueRepository` (+`retry`/`defer` preserve `resumeStateJson` untouched today - keep it that way), `MediaUploadPipeline.process`, `WorkerGate` (the Phase 2 runtime gate already checks `videosOnCellular` for video entries - lifting eligibility activates it with zero gate changes), `MediaStoreResolver.tryResolveRemote`, `mediaStoreRuntimeProvider`. + +--- + +### Task 1: Interface extension (progress + resume) and fake/contract updates + +**Files:** +- Modify: `lib/core/services/media_store/media_object_store.dart` +- Modify: `lib/core/services/media_store/s3_media_object_store.dart` (accept the new params; single-shot behavior unchanged this task) +- Modify: `test/helpers/in_memory_media_object_store.dart` +- Modify: `test/core/services/media_store/media_object_store_contract.dart` (+ its `_test.dart` unchanged) + +**Interfaces:** +- Produces (in `media_object_store.dart`): +```dart +/// Progress callback: [transferredBytes] so far; [totalBytes] null when +/// the total is unknown. +typedef TransferProgressCallback = + void Function(int transferredBytes, int? totalBytes); + +abstract class MediaObjectStore { + Future head(String key); + Future putFile( + String key, + File source, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }); + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }); + Future delete(String key); + Stream list(String keyPrefix); +} +``` +`resumeStateJson` is adapter-opaque JSON; callers persist whatever `onResumeStateChanged` hands them and replay it verbatim. + +- [ ] **Step 1: Write the failing contract additions** + +Append to the `group` in `media_object_store_contract.dart`: + +```dart + test('putFile and getFile report progress reaching the full size', + () async { + final bytes = List.generate(4096, (i) => i % 251); + final src = tempFile('p.bin', bytes); + final putProgress = []; + await store.putFile( + 'smv1/objects/aa/p.bin', + src, + contentType: 'application/octet-stream', + onProgress: (sent, total) => putProgress.add(sent), + ); + expect(putProgress, isNotEmpty); + expect(putProgress.last, bytes.length); + + final getProgress = []; + final dest = File('${tmp.path}/p.out'); + await store.getFile( + 'smv1/objects/aa/p.bin', + dest, + onProgress: (received, total) => getProgress.add(received), + ); + expect(getProgress, isNotEmpty); + expect(getProgress.last, bytes.length); + expect(await dest.readAsBytes(), bytes); + }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `set -o pipefail; flutter test test/core/services/media_store/media_object_store_contract_test.dart --timeout 60s 2>&1 | tail -3` +Expected: FAIL to compile (named parameters undefined). + +- [ ] **Step 3: Implement** + +(a) `media_object_store.dart`: apply the Interfaces block above (add the typedef above the class; extend the two signatures; document `resumeStateJson` as adapter-opaque). + +(b) `in_memory_media_object_store.dart`: extend both overrides; after storing bytes call `onProgress?.call(bytes.length, bytes.length);` (single completion tick); `getFile` likewise after writing. Ignore resume parameters. + +(c) `s3_media_object_store.dart`: extend both override signatures; in this task simply invoke `onProgress?.call(length, length)` after the existing single-shot put/get succeeds (the multipart internals arrive in Task 4). `resumeStateJson`/`onResumeStateChanged` are accepted and unused for sub-threshold objects. + +- [ ] **Step 4: Run to verify it passes** + +Run: `set -o pipefail; flutter test test/core/services/media_store/media_object_store_contract_test.dart test/core/services/media_store/s3_media_object_store_test.dart --timeout 60s 2>&1 | tail -2` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): progress and resume hooks on MediaObjectStore" +``` + +--- + +### Task 2: Queue v3 - progress columns, resume-state persistence + +**Files:** +- Modify: `lib/core/database/local_cache_database.dart` (columns + schemaVersion 3 + migration) +- Modify: `lib/features/media_store/data/media_transfer_queue_repository.dart` +- Test: modify `test/features/media_store/media_transfer_queue_repository_test.dart` + +**Interfaces:** +- `MediaTransferQueue` table gains `IntColumn get progressBytes => integer().nullable()();` and `IntColumn get totalBytes => integer().nullable()();`. +- Repository gains: +```dart +Future updateResumeState(int id, String? resumeStateJson); // null clears +Future updateProgress(int id, {required int transferredBytes, int? totalBytes}); +``` +- `markDone` additionally clears `resumeStateJson`, `progressBytes`, `totalBytes` (a finished transfer must not leak stale resume data into a future re-enqueue of the same media). `retry`, `defer`, and `markFailed` must PRESERVE `resumeStateJson` - that is the whole point of resume. + +- [ ] **Step 1: Write the failing tests** + +Append to the queue test file: + +```dart + test('v3 migration adds progress columns to an existing v2 database', + () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 2'); + rawDb.execute(''' + CREATE TABLE local_asset_cache ( + media_id TEXT NOT NULL PRIMARY KEY, + local_asset_id TEXT, + resolved_at INTEGER NOT NULL, + resolution_method TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0 + ) + '''); + rawDb.execute(''' + CREATE TABLE media_transfer_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id TEXT NOT NULL, + direction TEXT NOT NULL DEFAULT 'upload', + object_kind TEXT NOT NULL DEFAULT 'original', + content_hash TEXT, + state TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER, + resume_state_json TEXT, + error_message TEXT, + priority INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + rawDb.execute(''' + CREATE TABLE media_cache_entries ( + content_hash TEXT NOT NULL, + kind TEXT NOT NULL, + relative_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + last_accessed_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (content_hash, kind) + ) + '''); + rawDb.execute( + "INSERT INTO media_transfer_queue " + "(media_id, created_at, updated_at) VALUES ('m1', 1, 1)", + ); + }, + ); + final upgraded = LocalCacheDatabase(nativeDb); + addTearDown(upgraded.close); + + final cols = await upgraded + .customSelect("PRAGMA table_info('media_transfer_queue')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, containsAll(['progress_bytes', 'total_bytes'])); + final kept = await upgraded + .customSelect("SELECT media_id FROM media_transfer_queue") + .getSingle(); + expect(kept.data['media_id'], 'm1'); + }); + + test('resume state persists through markFailed and retry, and clears on ' + 'markDone', () async { + final id = await repo.enqueueUpload(mediaId: 'm1'); + await repo.updateResumeState(id, '{"uploadId":"u1"}'); + await repo.updateProgress(id, transferredBytes: 16, totalBytes: 64); + + await repo.markFailed(id, 'network blip'); + var row = (await repo.allForTesting()).single; + expect(row.resumeStateJson, '{"uploadId":"u1"}'); + expect(row.progressBytes, 16); + + for (var i = 0; i < 4; i++) { + await repo.markFailed(id, 'boom'); + } + await repo.retry(id); + row = (await repo.allForTesting()).single; + expect(row.resumeStateJson, '{"uploadId":"u1"}', + reason: 'retry must keep the resume point'); + + await repo.markDone(id); + row = (await repo.allForTesting()).single; + expect(row.resumeStateJson, isNull); + expect(row.progressBytes, isNull); + expect(row.totalBytes, isNull); + }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `set -o pipefail; flutter test test/features/media_store/media_transfer_queue_repository_test.dart --timeout 60s 2>&1 | tail -3` +Expected: FAIL to compile. + +- [ ] **Step 3: Implement** + +(a) Table: add the two nullable int columns to `MediaTransferQueue` (after `priority`). Bump `schemaVersion` to 3 and extend the migration: + +```dart + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.createTable(mediaTransferQueue); + await m.createTable(mediaCacheEntries); + } + if (from < 3) { + await m.addColumn(mediaTransferQueue, mediaTransferQueue.progressBytes); + await m.addColumn(mediaTransferQueue, mediaTransferQueue.totalBytes); + } + }, + ); +``` +NOTE: `if (from < 2)` creates the tables WITH the new columns (fresh Drift schema), so the `from < 3` ALTERs would fail on a database that just ran the v2 block. Guard: change the v3 block to `if (from >= 2 && from < 3)`. + +(b) Repository methods: + +```dart + /// Persists (or clears, with null) the adapter's opaque resume point. + Future updateResumeState(int id, String? resumeStateJson) async { + await (_db.update( + _db.mediaTransferQueue, + )..where((t) => t.id.equals(id))).write( + MediaTransferQueueCompanion( + resumeStateJson: Value(resumeStateJson), + updatedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + + Future updateProgress( + int id, { + required int transferredBytes, + int? totalBytes, + }) async { + await (_db.update( + _db.mediaTransferQueue, + )..where((t) => t.id.equals(id))).write( + MediaTransferQueueCompanion( + progressBytes: Value(transferredBytes), + totalBytes: Value(totalBytes), + updatedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } +``` +and extend `markDone`: + +```dart + Future markDone(int id) async { + await (_db.update( + _db.mediaTransferQueue, + )..where((t) => t.id.equals(id))).write( + MediaTransferQueueCompanion( + state: const Value('done'), + resumeStateJson: const Value(null), + progressBytes: const Value(null), + totalBytes: const Value(null), + updatedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } +``` +(replacing the `_setState` delegation for done only; `markTransferring` keeps `_setState`). + +- [ ] **Step 4: Codegen, run, commit** + +```bash +dart run build_runner build --delete-conflicting-outputs 2>&1 | tail -2 +set -o pipefail; flutter test test/features/media_store/media_transfer_queue_repository_test.dart --timeout 60s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): queue resume-state and progress persistence" +``` +Expected: PASS (12 tests). + +--- + +### Task 3: S3 multipart + Range operations and a fake S3 server helper + +**Files:** +- Modify: `lib/core/services/cloud_storage/s3/s3_api_client.dart` +- Create: `test/helpers/fake_s3_server.dart` (extracted+extended from the MockClient in `s3_media_object_store_test.dart`) +- Modify: `test/core/services/media_store/s3_media_object_store_test.dart` (use the helper) +- Test: `test/core/services/cloud_storage/s3/s3_multipart_test.dart` + +**Interfaces:** +- Produces (on `S3ApiClient`): +```dart +class S3PartInfo { + final int partNumber; + final String etag; + const S3PartInfo({required this.partNumber, required this.etag}); +} + +Future createMultipartUpload(String key, {required String contentType}); +Future uploadPart(String key, {required String uploadId, required int partNumber, required Uint8List bytes}); // returns etag +Future completeMultipartUpload(String key, {required String uploadId, required List parts}); +Future abortMultipartUpload(String key, {required String uploadId}); // idempotent (404 ok) +Future> listParts(String key, {required String uploadId}); +Future<({Uint8List bytes, int totalLength})> getObjectRange(String key, {required int start, required int endInclusive}); +``` +- Produces (test helper): +```dart +class FakeS3Server { + FakeS3Server({String bucket = 'test-bucket'}); + final Map objects; // wire key -> bytes + final List captured; + Exception? failNextWith; // one-shot injection + int partUploadCount; // PUT ?partNumber requests seen + MockClient get client; +} +``` +The handler implements: plain PUT/GET/HEAD/DELETE/list-type=2 (as today), POST `?uploads` (returns `...`), PUT `?partNumber=N&uploadId=U` (stores the part, returns an `ETag` header `"part-N-"`), POST `?uploadId=U` (concatenates parts in order into `objects[key]`), DELETE `?uploadId=U` (drops the session; 204 even if unknown), GET `?uploadId=U` (ListParts XML with ``), and GET with a `Range: bytes=a-b` header (206, `Content-Range: bytes a-b/total`, sliced body). + +- [ ] **Step 1: Write the failing multipart client test** + +Create `test/core/services/cloud_storage/s3/s3_multipart_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/cloud_storage/s3/s3_api_client.dart'; +import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; + +import '../../../../helpers/fake_s3_server.dart'; + +void main() { + late FakeS3Server server; + late S3ApiClient client; + + setUp(() { + server = FakeS3Server(); + client = S3ApiClient( + S3Config( + endpoint: 'http://localhost:9000', + bucket: 'test-bucket', + prefix: '', + accessKeyId: 'AK', + secretAccessKey: 'SK', + ), + httpClient: server.client, + ); + }); + + test('multipart create, upload, list, complete round-trips bytes', + () async { + final uploadId = await client.createMultipartUpload( + 'big.bin', + contentType: 'application/octet-stream', + ); + expect(uploadId, isNotEmpty); + + final part1 = Uint8List.fromList(List.filled(10, 1)); + final part2 = Uint8List.fromList(List.filled(6, 2)); + final etag1 = await client.uploadPart( + 'big.bin', + uploadId: uploadId, + partNumber: 1, + bytes: part1, + ); + final etag2 = await client.uploadPart( + 'big.bin', + uploadId: uploadId, + partNumber: 2, + bytes: part2, + ); + + final listed = await client.listParts('big.bin', uploadId: uploadId); + expect(listed.map((p) => p.partNumber).toList(), [1, 2]); + expect(listed.map((p) => p.etag).toList(), [etag1, etag2]); + + await client.completeMultipartUpload( + 'big.bin', + uploadId: uploadId, + parts: [ + S3PartInfo(partNumber: 1, etag: etag1), + S3PartInfo(partNumber: 2, etag: etag2), + ], + ); + expect(server.objects['big.bin'], [...part1, ...part2]); + }); + + test('abort discards the session and is idempotent', () async { + final uploadId = await client.createMultipartUpload( + 'gone.bin', + contentType: 'application/octet-stream', + ); + await client.abortMultipartUpload('gone.bin', uploadId: uploadId); + await client.abortMultipartUpload('gone.bin', uploadId: uploadId); + expect(server.objects.containsKey('gone.bin'), isFalse); + }); + + test('getObjectRange returns the slice and the total length', () async { + server.objects['r.bin'] = Uint8List.fromList( + List.generate(100, (i) => i), + ); + final range = await client.getObjectRange( + 'r.bin', + start: 10, + endInclusive: 19, + ); + expect(range.bytes, List.generate(10, (i) => i + 10)); + expect(range.totalLength, 100); + }); +} +``` + +- [ ] **Step 2: Extract the FakeS3Server helper** + +Move the MockClient handler out of `s3_media_object_store_test.dart` into `test/helpers/fake_s3_server.dart` per the Interfaces block, extending it with the multipart and Range branches. Session storage inside the fake: `Map> _sessions` keyed by uploadId; uploadId generation `'upload-${_sessions.length + 1}'`. The adapter test file then builds its store as `S3MediaObjectStore(client: S3ApiClient(config, httpClient: server.client), keyPrefix: config.prefix)` and asserts against `server.objects` / `server.captured` (same assertions as before; only plumbing moves). + +- [ ] **Step 3: Run to verify failure, implement the client operations** + +Run the new test -> FAIL to compile. Add the operations to `s3_api_client.dart` (below `listObjects`, above `close`): + +```dart + /// Starts a multipart upload session; returns the server's uploadId. + Future createMultipartUpload( + String key, { + required String contentType, + }) async { + final response = await _sendWithRetry( + 'POST', + key, + queryParams: {'uploads': ''}, + ); + if (response.statusCode != 200) _throwFor('start upload', key, response); + final uploadId = _xmlElementText(response.body, 'UploadId'); + if (uploadId == null || uploadId.isEmpty) { + throw CloudStorageException( + 'S3 returned no UploadId for "$key"', + ); + } + return uploadId; + } + + /// Uploads one part; returns its ETag for the completion manifest. + Future uploadPart( + String key, { + required String uploadId, + required int partNumber, + required Uint8List bytes, + }) async { + final response = await _sendWithRetry( + 'PUT', + key, + queryParams: {'partNumber': '$partNumber', 'uploadId': uploadId}, + body: bytes, + ); + if (response.statusCode != 200) { + _throwFor('upload part $partNumber of', key, response); + } + final etag = response.headers['etag']; + if (etag == null || etag.isEmpty) { + throw CloudStorageException( + 'S3 returned no ETag for part $partNumber of "$key"', + ); + } + return etag; + } + + Future completeMultipartUpload( + String key, { + required String uploadId, + required List parts, + }) async { + final manifest = StringBuffer(''); + for (final part in parts) { + manifest.write( + '${part.partNumber}' + '${part.etag}', + ); + } + manifest.write(''); + final response = await _sendWithRetry( + 'POST', + key, + queryParams: {'uploadId': uploadId}, + body: Uint8List.fromList(utf8.encode(manifest.toString())), + ); + if (response.statusCode != 200) _throwFor('finish upload', key, response); + // S3 reports completion errors inside a 200 body. + if (_xmlElementText(response.body, 'Code') != null) { + throw CloudStorageException( + 'S3 rejected the upload completion for "$key"', + ); + } + } + + /// Idempotent: aborting an unknown session succeeds. + Future abortMultipartUpload( + String key, { + required String uploadId, + }) async { + final response = await _sendWithRetry( + 'DELETE', + key, + queryParams: {'uploadId': uploadId}, + ); + const okStatuses = {200, 204, 404}; + if (!okStatuses.contains(response.statusCode)) { + _throwFor('abort upload', key, response); + } + } + + Future> listParts( + String key, { + required String uploadId, + }) async { + final response = await _sendWithRetry( + 'GET', + key, + queryParams: {'uploadId': uploadId}, + ); + if (response.statusCode != 200) _throwFor('list parts of', key, response); + try { + final document = XmlDocument.parse(response.body); + return document + .findAllElements('Part') + .map( + (part) => S3PartInfo( + partNumber: int.parse( + part.getElement('PartNumber')!.innerText, + ), + etag: part.getElement('ETag')!.innerText, + ), + ) + .toList(); + } on Exception catch (e) { + throw CloudStorageException('S3 returned an unreadable part list', e); + } + } + + /// Byte-range read: [start]..[endInclusive]. Returns the slice and the + /// object's total length parsed from Content-Range. + Future<({Uint8List bytes, int totalLength})> getObjectRange( + String key, { + required int start, + required int endInclusive, + }) async { + final response = await _sendWithRetry( + 'GET', + key, + extraHeaders: {'range': 'bytes=$start-$endInclusive'}, + ); + if (response.statusCode == 404) { + throw CloudStorageException('File not found in S3: $key'); + } + if (response.statusCode != 206 && response.statusCode != 200) { + _throwFor('download range of', key, response); + } + final contentRange = response.headers['content-range']; + final total = contentRange == null + ? response.bodyBytes.length + : int.parse(contentRange.split('/').last); + return (bytes: response.bodyBytes, totalLength: total); + } +``` +with `class S3PartInfo` beside `S3ObjectInfo` at the top of the file. + +`_sendWithRetry`/`_send` need an `extraHeaders` pass-through for the Range header: add `Map extraHeaders = const {}` to both, merged into the request AFTER signing (`request.headers.addAll(extraHeaders)` alongside the signed headers - the Range header is not part of the SigV4 canonical headers this client signs, which S3 accepts since only signed headers participate in the signature). + +- [ ] **Step 4: Run all Task 3 tests** + +```bash +set -o pipefail +flutter test test/core/services/cloud_storage/s3/s3_multipart_test.dart test/core/services/media_store/s3_media_object_store_test.dart --timeout 60s 2>&1 | tail -2 +``` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): S3 multipart and range operations" +``` + +--- + +### Task 4: S3 adapter large-object putFile (multipart + resume) and streamed getFile + +**Files:** +- Modify: `lib/core/services/media_store/s3_media_object_store.dart` +- Test: modify `test/core/services/media_store/s3_media_object_store_test.dart` + +**Interfaces:** +- Constructor gains tuning knobs (tests shrink them; production uses defaults): +```dart +S3MediaObjectStore({ + required S3ApiClient client, + required String keyPrefix, + int partSizeBytes = 8 * 1024 * 1024, // also the multipart threshold + int downloadChunkBytes = 8 * 1024 * 1024, +}); +``` +- Resume JSON shape (adapter-owned): `{"uploadId": "...", "partSizeBytes": N, "parts": [{"n": 1, "etag": "..."}]}`. +- `putFile`: size <= partSizeBytes -> existing single-shot + one progress tick. Larger -> multipart: + 1. Parse `resumeStateJson`; if present, `listParts` and verify every recorded part number/etag appears server-side AND the recorded partSizeBytes matches the current one - on any mismatch `abortMultipartUpload` (best-effort) and start fresh. + 2. Otherwise `createMultipartUpload`. + 3. For each remaining part index: read the slice via `RandomAccessFile` (`setPosition` + `read` - never the whole file), `uploadPart`, append to the parts list, fire `onResumeStateChanged(json)` then `onProgress(min(n*partSize, size), size)`. + 4. `completeMultipartUpload` with all parts in order. +- `getFile`: `head` for the size (null -> notFound MediaStoreException, matching the single-shot contract); loop `getObjectRange` chunks appended to the destination via a `RandomAccessFile` opened in write mode, `onProgress` after each chunk. Objects at or below `downloadChunkBytes` keep the existing single `getObject` call. +- Error mapping stays `_map(...)`; a failure mid-multipart propagates AFTER `onResumeStateChanged` has already recorded completed parts, so the caller retries from the last acknowledged part. + +- [ ] **Step 1: Write the failing adapter tests** + +Append to `s3_media_object_store_test.dart` (the `build` helper gains optional `partSizeBytes`/`downloadChunkBytes` parameters passed through, default 64 KiB in these tests via `build(partSizeBytes: 64 * 1024, downloadChunkBytes: 64 * 1024)`): + +```dart + test('large putFile goes multipart, reports progress, and round-trips', + () async { + final store = build(partSizeBytes: 64 * 1024, downloadChunkBytes: 64 * 1024); + final bytes = List.generate(200 * 1024, (i) => i % 251); + final src = File('${tmp.path}/video.mp4')..writeAsBytesSync(bytes); + + final progress = []; + String? resumeJson; + await store.putFile( + 'smv1/objects/aa/video.mp4', + src, + contentType: 'video/mp4', + onProgress: (sent, total) => progress.add(sent), + onResumeStateChanged: (json) => resumeJson = json, + ); + + expect(server.objects['submersion-media/smv1/objects/aa/video.mp4'], + bytes); + expect(server.partUploadCount, 4, reason: '200KiB / 64KiB = 4 parts'); + expect(progress.last, bytes.length); + expect(resumeJson, contains('"uploadId"')); + + final dest = File('${tmp.path}/video.out'); + final getProgress = []; + await store.getFile( + 'smv1/objects/aa/video.mp4', + dest, + onProgress: (received, total) => getProgress.add(received), + ); + expect(await dest.readAsBytes(), bytes); + expect(getProgress.length, greaterThan(1), reason: 'chunked download'); + expect(getProgress.last, bytes.length); + }); + + test('kill-and-resume: a mid-upload failure resumes from the last ' + 'acknowledged part without re-uploading it', () async { + final store = build(partSizeBytes: 64 * 1024); + final bytes = List.generate(200 * 1024, (i) => (i * 3) % 251); + final src = File('${tmp.path}/kr.mp4')..writeAsBytesSync(bytes); + + String? resumeJson; + server.failAfterPartUploads = 2; // parts 1-2 succeed, part 3 dies + await expectLater( + store.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + onResumeStateChanged: (json) => resumeJson = json, + ), + throwsA(isA()), + ); + expect(resumeJson, isNotNull); + final partsBefore = server.partUploadCount; + expect(partsBefore, 2); + + // "Restart the app": a fresh store instance resumes from the JSON. + server.failAfterPartUploads = null; + final resumed = build(partSizeBytes: 64 * 1024); + await resumed.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: resumeJson, + onResumeStateChanged: (json) => resumeJson = json, + ); + expect(server.objects['submersion-media/smv1/objects/aa/kr.mp4'], bytes); + expect(server.partUploadCount - partsBefore, 2, + reason: 'only parts 3-4 upload on resume'); + }); + + test('a stale resume state (unknown uploadId) aborts and restarts fresh', + () async { + final store = build(partSizeBytes: 64 * 1024); + final bytes = List.generate(130 * 1024, (i) => i % 199); + final src = File('${tmp.path}/stale.mp4')..writeAsBytesSync(bytes); + + await store.putFile( + 'smv1/objects/aa/stale.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: + '{"uploadId":"upload-does-not-exist","partSizeBytes":65536,' + '"parts":[{"n":1,"etag":"\\"bogus\\""}]}', + ); + expect(server.objects['submersion-media/smv1/objects/aa/stale.mp4'], + bytes); + }); +``` +The fake gains `int? failAfterPartUploads;` - when set, part-upload PUTs beyond that count return a 500 once (then the field clears). `listParts` for an unknown uploadId returns 404 (the client's generic `_throwFor` maps it; the adapter treats ANY listParts failure as stale-resume -> fresh start). + +- [ ] **Step 2: Run to verify failure, implement** + +Run -> FAIL. Implement `putFile`/`getFile` per the Interfaces block. Structure inside the adapter: + +```dart + @override + Future putFile(...) async { + final length = await source.length(); + if (length <= partSizeBytes) { /* existing single-shot + progress */ } + return _putMultipart(key, source, length, contentType: contentType, + onProgress: onProgress, resumeStateJson: resumeStateJson, + onResumeStateChanged: onResumeStateChanged); + } +``` +`_putMultipart` private method implements resume-validate/create, the `RandomAccessFile` part loop, and completion; `_resumeFromJson`/`_resumeToJson` are small private helpers using `jsonDecode`/`jsonEncode` (import `dart:convert`). All client calls stay wrapped by `_map` via try/catch around the whole multipart body, EXCEPT the stale-resume `listParts` probe which catches locally and falls through to a fresh session. + +- [ ] **Step 3: Run all adapter + multipart + contract tests** + +```bash +set -o pipefail +flutter test test/core/services/media_store/ test/core/services/cloud_storage/s3/s3_multipart_test.dart --timeout 90s 2>&1 | tail -2 +``` +Expected: PASS. + +- [ ] **Step 4: Format, analyze, commit** + +```bash +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): multipart upload with resume and streamed download" +``` + +--- + +### Task 5: Pipeline - video eligibility, resume/progress wiring, gallery-video thumbs + +**Files:** +- Modify: `lib/features/media_store/data/media_upload_pipeline.dart` +- Test: modify `test/features/media_store/media_upload_pipeline_test.dart` + +**Interfaces:** +- `_isEligible` drops the `MediaType.video` rejection (delete the Phase 2 lines and their comment). The Phase 2 runtime gate already routes video entries through `policies.videosOnCellular()` - no gate change. +- `process()` threads the queue row into the store call: +```dart + await _mediaRepository.stampContentIdentity(...); // existing + // ... thumb step (existing, unchanged - gallery videos produce + // BytesData posters through resolveThumbnail already) ... + final existing = await _store.head(key); + if (existing == null) { + await _store.putFile( + key, + staged, + contentType: StoreKeys.contentTypeFor(extension), + resumeStateJson: entry.resumeStateJson, + onResumeStateChanged: (json) => + unawaited(_queue.updateResumeState(entry.id, json)), + onProgress: (sent, total) => unawaited( + _queue.updateProgress(entry.id, transferredBytes: sent, + totalBytes: total ?? digest.sizeBytes), + ), + ); + } +``` +(`import 'dart:async';` for `unawaited`. `markDone` clears resume/progress - Task 2.) +- One behavioral note to encode in a test: `_materialize` COPIES the source to staging, and the staged file is deleted in `finally` - so on failure the resume state refers to a byte-identical future staging copy. Content-addressing makes this safe: the same source bytes re-materialize identically, and part boundaries depend only on partSize + content. The test asserts a failed-then-retried upload with a recording fake receives the SAME resumeStateJson back. + +- [ ] **Step 1: Write the failing tests** + +Replace the Phase 2 `video rows are ineligible until Phase 3` test with: + +```dart + test('video rows upload with contentType video/mp4', () async { + final id = await enqueueLocalFileItem( + bytes: List.generate(1024, (i) => i % 251), + name: 'clip.mp4', + mediaType: domain.MediaType.video, + ); + final entry = (await queue.nextPending(DateTime.now()))!; + expect(await pipeline.process(entry), UploadOutcome.uploaded); + final item = (await mediaRepository.getMediaById(id))!; + expect(item.remoteUploadedAt, isNotNull); + final key = + 'smv1/objects/${item.contentHash!.substring(0, 2)}/' + '${item.contentHash}.mp4'; + expect(fakeStore.objects.containsKey(key), isTrue); + }); + + test('resume state and progress flow through the queue row', () async { + // The InMemory fake accepts and echoes resume hooks: extend it first + // (see Step 2) so putFile calls onResumeStateChanged('{"fake":1}') + // and onProgress(length, length). + final id = await enqueueLocalFileItem(bytes: [1, 2, 3, 4], name: 'r.jpg'); + final entry = (await queue.nextPending(DateTime.now()))!; + fakeStore.emitResumeState = '{"fake":1}'; + expect(await pipeline.process(entry), UploadOutcome.uploaded); + + // markDone cleared them; the intermediate write is what we assert via + // the fake's recorded call. + expect(fakeStore.lastResumeStateJsonIn, isNull, + reason: 'first attempt starts with no resume state'); + final row = (await queue.allForTesting()).single; + expect(row.state, 'done'); + expect(row.resumeStateJson, isNull); + + // Second media with a pre-seeded resume state on the row reaches the + // store call. + final id2 = await enqueueLocalFileItem(bytes: [9, 9], name: 'r2.jpg'); + final entry2 = (await queue.nextPending(DateTime.now()))!; + await queue.updateResumeState(entry2.id, '{"seeded":true}'); + final refreshed = (await queue.nextPending(DateTime.now()))!; + expect(await pipeline.process(refreshed), UploadOutcome.uploaded); + expect(fakeStore.lastResumeStateJsonIn, '{"seeded":true}'); + expect((await mediaRepository.getMediaById(id2))!.remoteUploadedAt, + isNotNull); + expect(id, isNot(id2)); + }); + + test('gallery-style video thumbs upload (BytesData poster)', () async { + final png = pngBytes(); + final id = await enqueueLocalFileItem( + bytes: List.generate(2048, (i) => i % 251), + name: 'dive.mp4', + mediaType: domain.MediaType.video, + thumbnailData: BytesData(bytes: Uint8List.fromList(png)), + ); + final entry = (await queue.nextPending(DateTime.now()))!; + expect(await pipeline.process(entry), UploadOutcome.uploaded); + final item = (await mediaRepository.getMediaById(id))!; + expect(item.remoteThumbUploadedAt, isNotNull); + expect( + fakeStore.objects.containsKey( + 'smv1/thumbs/${item.contentHash!.substring(0, 2)}/' + '${item.contentHash}.jpg', + ), + isTrue, + ); + }); +``` +Supporting changes in the test file: `_FakeLocalFileResolver` gains an optional `thumbnailData` field returned by `resolveThumbnail` when set (falling back to `data`); `enqueueLocalFileItem` gains `MediaSourceData? thumbnailData` applied to the resolver; add `import 'dart:typed_data';`. + +- [ ] **Step 2: Extend the InMemory fake** + +In `test/helpers/in_memory_media_object_store.dart` add fields and wire them in `putFile`: + +```dart + /// When set, putFile fires onResumeStateChanged with this JSON once. + String? emitResumeState; + + /// The resumeStateJson the last putFile call received. + String? lastResumeStateJsonIn; +``` +(`lastResumeStateJsonIn = resumeStateJson;` at the top of putFile; after storing bytes, `if (emitResumeState != null) onResumeStateChanged?.call(emitResumeState!);` then the progress tick.) + +- [ ] **Step 3: Run to verify failure, implement the pipeline change, re-run** + +Run the pipeline test -> FAIL. Apply the `_isEligible` deletion and the `putFile` call-site wiring from the Interfaces block. Re-run: + +```bash +set -o pipefail +flutter test test/features/media_store/media_upload_pipeline_test.dart test/features/media_store/media_store_end_to_end_test.dart --timeout 60s 2>&1 | tail -2 +``` +Expected: PASS (the e2e gate test used a photo; unaffected). + +- [ ] **Step 4: Format, analyze, commit** + +```bash +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): video uploads with resume and progress wiring" +``` + +--- + +### Task 6: Video playback fallback in resolvedFilePathProvider + +**Files:** +- Modify: `lib/features/media/presentation/providers/resolved_asset_providers.dart` (`resolvedFilePathProvider`, ~line 89) +- Test: `test/features/media_store/resolved_file_path_store_fallback_test.dart` + +**Interfaces:** +- `resolvedFilePathProvider` behavior becomes: gallery resolution (existing) -> if null, `item.localPath` when the file exists -> else store fallback via `mediaStoreRuntimeProvider`'s resolver (`tryResolveRemote(item, thumbnail: false)`, returning `FileData.file.path`) -> else null. `photo_viewer_page._VideoItem` (line 468) is untouched: its existing loading spinner covers the download. + +- [ ] **Step 1: Write the failing test** + +Create `test/features/media_store/resolved_file_path_store_fallback_test.dart` (ProviderContainer test; the gallery service path requires `photoPickerServiceProvider` - avoid it by overriding `assetResolutionServiceProvider`? That service class is concrete; instead use an item with `platformAssetId: null`, which makes the existing gallery branch return unavailable without touching the picker service - verify by reading the provider body first): + +```dart +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/core/services/media_store/store_keys.dart'; +import 'package:submersion/features/media/data/resolvers/media_store_resolver.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; + +import '../../helpers/in_memory_media_object_store.dart'; + +void main() { + late LocalCacheDatabase db; + late Directory root; + late InMemoryMediaObjectStore store; + late ProviderContainer container; + + setUp(() async { + db = LocalCacheDatabase(NativeDatabase.memory()); + root = await Directory.systemTemp.createTemp('rfp_fallback'); + store = InMemoryMediaObjectStore(); + final cache = MediaCacheStore(database: db, root: root); + final runtime = MediaStoreRuntime( + storeId: 's1', + store: store, + cache: cache, + resolver: MediaStoreResolver(store: store, cache: cache), + ); + container = ProviderContainer( + overrides: [ + mediaStoreRuntimeProvider.overrideWith((ref) async => runtime), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await db.close(); + await root.delete(recursive: true); + }); + + MediaItem videoItem({required String hash, String? localPath}) => MediaItem( + id: 'v1', + mediaType: MediaType.video, + sourceType: MediaSourceType.localFile, + localPath: localPath, + originalFilename: 'dive.mp4', + takenAt: DateTime(2026), + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + contentHash: hash, + remoteUploadedAt: DateTime(2026, 7, 1), + ); + + test('existing localPath wins without touching the store', () async { + final local = File('${root.path}/here.mp4')..writeAsBytesSync([1, 2]); + final path = await container.read( + resolvedFilePathProvider( + videoItem(hash: 'a' * 64, localPath: local.path), + ).future, + ); + expect(path, local.path); + expect(store.objects, isEmpty); + }); + + test('dead localPath falls back to the store and returns playable bytes', + () async { + final bytes = List.generate(4096, (i) => (i * 5) % 251); + final seed = File('${root.path}/seed.mp4')..writeAsBytesSync(bytes); + final digest = await sha256OfFile(seed); + store.objects[StoreKeys.objectKey(digest.hash, extension: 'mp4')] = bytes; + + final path = await container.read( + resolvedFilePathProvider( + videoItem( + hash: digest.hash, + localPath: '/nonexistent/on/this/device.mp4', + ), + ).future, + ); + expect(path, isNotNull); + expect(await File(path!).readAsBytes(), bytes); + }); + + test('no store and no local file yields null', () async { + final bare = ProviderContainer( + overrides: [ + mediaStoreRuntimeProvider.overrideWith((ref) async => null), + ], + ); + addTearDown(bare.dispose); + final path = await bare.read( + resolvedFilePathProvider( + videoItem(hash: 'b' * 64, localPath: '/nope.mp4'), + ).future, + ); + expect(path, isNull); + }); +} +``` +NOTE: `resolvedFilePathProvider` is a plain FutureProvider.family - `.future` reads resolve without the auto-pause stream caveat. If the provider body's FIRST step (`assetResolutionServiceProvider`) throws for a null `platformAssetId` instead of returning unavailable, adjust the implementation ordering so the null-assetId short-circuit comes first (Step 2 reads the body before editing). + +- [ ] **Step 2: Read, then implement the fallback** + +Read `resolved_asset_providers.dart` in full. Rework `resolvedFilePathProvider` to: + +```dart +final resolvedFilePathProvider = FutureProvider.family(( + ref, + item, +) async { + // Gallery fast path (existing behavior, only when an asset id exists). + if (item.platformAssetId != null) { + final service = ref.watch(assetResolutionServiceProvider); + final resolution = await service.resolveAssetId(item); + if (resolution.status != ResolutionStatus.unavailable && + resolution.localAssetId != null) { + final pickerService = ref.watch(photoPickerServiceProvider); + final path = await pickerService.getFilePath(resolution.localAssetId!); + if (path != null) return path; + } + } + + // Device-local file (localFile source rows). + final localPath = item.localPath; + if (localPath != null && await File(localPath).exists()) { + return localPath; + } + + // Media store fallback (design spec section 10): download the original + // into the content-addressed cache and play from there. + final runtime = await ref.read(mediaStoreRuntimeProvider.future); + final data = await runtime?.resolver.tryResolveRemote( + item, + thumbnail: false, + ); + if (data is FileData) return data.file.path; + return null; +}); +``` +Add imports (`media_source_data.dart`, `media_store_providers.dart`). Preserve the existing doc comment, extending it with the fallback order. + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/features/media_store/resolved_file_path_store_fallback_test.dart test/features/media/ --timeout 60s 2>&1 | tail -1 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): store-backed video playback path" +``` +Expected: PASS including the full media suite (photo_viewer widget tests exercise the provider's gallery branch). + +--- + +### Task 7: Transfers view progress bars + +**Files:** +- Modify: `lib/features/media_store/presentation/pages/transfers_page.dart` (`_TransferTile`) +- Test: modify `test/features/media_store/transfers_page_test.dart` + +**Interfaces:** +- A `transferring` row with `progressBytes` and `totalBytes` renders a determinate `LinearProgressIndicator(value: progress/total)` under the title (via `ListTile.subtitle` column); without totals it renders indeterminate. Other states unchanged. + +- [ ] **Step 1: Write the failing test** + +Append to `transfers_page_test.dart`: + +```dart + testWidgets('transferring entries render a determinate progress bar', ( + tester, + ) async { + late List snapshot; + await tester.runAsync(() async { + final id = await repo.enqueueUpload(mediaId: 'm-v'); + await repo.markTransferring(id); + await repo.updateProgress(id, transferredBytes: 25, totalBytes: 100); + snapshot = await repo.watchEntries().first; + }); + + await tester.pumpWidget(app(snapshot)); + await tester.pump(); + + final bar = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(bar.value, 0.25); + }); +``` + +- [ ] **Step 2: Run to verify failure, implement** + +Run -> FAIL (no progress bar). In `_TransferTile`, replace the `subtitle:` expression with: + +```dart + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (entry.errorMessage != null) + Text(entry.errorMessage!, maxLines: 2) + else + Text(entry.mediaId, maxLines: 1), + if (entry.state == 'transferring') + Padding( + padding: const EdgeInsets.only(top: 4), + child: LinearProgressIndicator( + value: + (entry.progressBytes != null && + entry.totalBytes != null && + entry.totalBytes! > 0) + ? entry.progressBytes! / entry.totalBytes! + : null, + ), + ), + ], + ), +``` + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/features/media_store/transfers_page_test.dart --timeout 60s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): transfer progress bars" +``` + +--- + +### Task 8: Phase 3 exit verification + stacked PR + +**Files:** +- Test: modify `test/features/media_store/media_store_end_to_end_test.dart` + +- [ ] **Step 1: Cross-device video e2e** + +Append (device fixtures exist; mirror the thumb e2e's structure): + +```dart + test('a video uploaded on device A plays from the store on device B', () async { + // Device A: localFile video with a BytesData poster thumb. + // (FakeLocalFileResolver gains the same optional thumbnailData field + // used by the pipeline test's fake - add it to + // test/features/media_store/support/fake_local_file_resolver.dart with + // default null falling back to `data`.) + // 1. create video MediaItem (mp4, 64 KiB generated bytes), enqueue, + // drain via a worker with no gate. + // 2. assert remoteUploadedAt + remoteThumbUploadedAt set and the bucket + // holds thumb + original. + // 3. device B: copyWith dead localPath; MediaStoreResolver + // tryResolveRemote(thumbnail: false) returns FileData whose bytes + // equal the source video's; thumbnail: true returns JPEG bytes. + }); +``` +Write it fully - every numbered line above is one assertion block, and the first e2e test in the file is the fixture template. + +- [ ] **Step 2: Full gates** + +```bash +set -o pipefail +flutter test test/features/media_store/ test/core/services/media_store/ test/core/services/cloud_storage/s3/ --timeout 90s 2>&1 | tail -1 +flutter test test/features/media/ 2>&1 | tail -1 +flutter test test/core/services/sync/ 2>&1 | tail -1 +flutter test test/features/settings/ 2>&1 | tail -1 +dart format . && flutter analyze +``` +Expected: all PASS, no issues. + +- [ ] **Step 3: Commit, push, stacked PR** + +```bash +git add -A && git commit -m "feat(media-store): phase 3 exit coverage" +git push -u origin worktree-media-store-phase3 --no-verify +env -u GITHUB_TOKEN gh pr create \ + --title "feat(media-store): large objects, phase 3 (multipart, resume, video)" \ + --base worktree-media-store-phase2 --head worktree-media-store-phase3 \ + --body-file /pr_body_phase3.md +``` +PR body follows the repo template; states STACKED on #556 (which stacks on #550, merge bottom-up); test plan lists the kill-and-resume adapter proof, the cross-device video e2e, and the pending manual items (MinIO smoke now including a real multi-hundred-MB video, playback walkthrough on a second device); notes the documented deviation (non-gallery video thumbs deferred to Phase 5). + +## Phase 3 exit criteria (spec section 17) + +- [ ] Multipart upload with per-part resume state; kill-and-resume proven at the adapter level (parts 1-2 not re-uploaded) +- [ ] Downloads stream in bounded-memory chunks with progress +- [ ] Videos eligible end-to-end: upload with video/mp4 content type, cellular gating via the existing videosOnCellular policy, poster thumbs for gallery videos +- [ ] Video on device B plays from the store via resolvedFilePathProvider fallback +- [ ] Transfers view shows determinate progress for in-flight transfers + From 415bbd589aff54459c4974615d6db0afcdcd7498 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:27:00 -0400 Subject: [PATCH 02/18] feat(media-store): progress and resume hooks on MediaObjectStore --- .../media_store/media_object_store.dart | 32 +++++++++++++++---- .../media_store/s3_media_object_store.dart | 11 ++++++- .../media_object_store_contract.dart | 26 +++++++++++++++ .../helpers/in_memory_media_object_store.dart | 14 ++++++-- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/lib/core/services/media_store/media_object_store.dart b/lib/core/services/media_store/media_object_store.dart index 5a80c49dd..ba79c8909 100644 --- a/lib/core/services/media_store/media_object_store.dart +++ b/lib/core/services/media_store/media_object_store.dart @@ -28,23 +28,43 @@ class MediaStoreException implements Exception { String toString() => 'MediaStoreException(${kind.name}): $message'; } +/// Progress callback: [transferredBytes] so far; [totalBytes] null when +/// the total is unknown. +typedef TransferProgressCallback = + void Function(int transferredBytes, int? totalBytes); + /// Object storage for media bytes (design spec section 8.1). /// /// Deliberately file-based, never whole-Uint8List in its contract, so the -/// interface survives Phase 3's multipart/streaming internals without -/// change and callers keep memory bounded for arbitrarily large media. -/// Keys are store-relative (see StoreKeys); adapters apply any -/// user-configured remote prefix. +/// interface survives multipart/streaming internals without change and +/// callers keep memory bounded for arbitrarily large media. Keys are +/// store-relative (see StoreKeys); adapters apply any user-configured +/// remote prefix. abstract class MediaObjectStore { /// Object metadata, or null when [key] does not exist. Future head(String key); /// Uploads [source] to [key], overwriting any existing object. - Future putFile(String key, File source, {required String contentType}); + /// + /// [resumeStateJson] is adapter-opaque JSON from a previous interrupted + /// attempt; callers persist whatever [onResumeStateChanged] hands them + /// and replay it verbatim on retry. + Future putFile( + String key, + File source, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }); /// Downloads [key] into [destination]. Throws a [MediaStoreException] /// with [MediaStoreErrorKind.notFound] when the object is absent. - Future getFile(String key, File destination); + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }); /// Idempotent delete: absent keys succeed. Future delete(String key); diff --git a/lib/core/services/media_store/s3_media_object_store.dart b/lib/core/services/media_store/s3_media_object_store.dart index 6d38e3f70..6ada93b4b 100644 --- a/lib/core/services/media_store/s3_media_object_store.dart +++ b/lib/core/services/media_store/s3_media_object_store.dart @@ -41,6 +41,9 @@ class S3MediaObjectStore implements MediaObjectStore { String key, File source, { required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, }) async { final Uint8List bytes; try { @@ -54,16 +57,22 @@ class S3MediaObjectStore implements MediaObjectStore { } try { await _client.putObject(_wire(key), bytes); + onProgress?.call(bytes.length, bytes.length); } on CloudStorageException catch (e) { throw _map('put', key, e); } } @override - Future getFile(String key, File destination) async { + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }) async { try { final bytes = await _client.getObject(_wire(key)); await destination.writeAsBytes(bytes, flush: true); + onProgress?.call(bytes.length, bytes.length); } on CloudStorageException catch (e) { throw _map('get', key, e); } diff --git a/test/core/services/media_store/media_object_store_contract.dart b/test/core/services/media_store/media_object_store_contract.dart index 4e49eacad..5f5ec3354 100644 --- a/test/core/services/media_store/media_object_store_contract.dart +++ b/test/core/services/media_store/media_object_store_contract.dart @@ -89,5 +89,31 @@ void runMediaObjectStoreContract( final keys = await store.list('smv1/objects/').map((o) => o.key).toList(); expect(keys, ['smv1/objects/aa/one.bin']); }); + + test('putFile and getFile report progress reaching the full ' + 'size', () async { + final bytes = List.generate(4096, (i) => i % 251); + final src = tempFile('p.bin', bytes); + final putProgress = []; + await store.putFile( + 'smv1/objects/aa/p.bin', + src, + contentType: 'application/octet-stream', + onProgress: (sent, total) => putProgress.add(sent), + ); + expect(putProgress, isNotEmpty); + expect(putProgress.last, bytes.length); + + final getProgress = []; + final dest = File('${tmp.path}/p.out'); + await store.getFile( + 'smv1/objects/aa/p.bin', + dest, + onProgress: (received, total) => getProgress.add(received), + ); + expect(getProgress, isNotEmpty); + expect(getProgress.last, bytes.length); + expect(await dest.readAsBytes(), bytes); + }); }); } diff --git a/test/helpers/in_memory_media_object_store.dart b/test/helpers/in_memory_media_object_store.dart index 9aa90103c..f5ba1d5a8 100644 --- a/test/helpers/in_memory_media_object_store.dart +++ b/test/helpers/in_memory_media_object_store.dart @@ -35,14 +35,23 @@ class InMemoryMediaObjectStore implements MediaObjectStore { String key, File source, { required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, }) async { _maybeFail(); - objects[key] = await source.readAsBytes(); + final bytes = await source.readAsBytes(); + objects[key] = bytes; modified[key] = DateTime.now(); + onProgress?.call(bytes.length, bytes.length); } @override - Future getFile(String key, File destination) async { + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }) async { _maybeFail(); final bytes = objects[key]; if (bytes == null) { @@ -52,6 +61,7 @@ class InMemoryMediaObjectStore implements MediaObjectStore { ); } await destination.writeAsBytes(bytes, flush: true); + onProgress?.call(bytes.length, bytes.length); } @override From cb3a91c9dec8edecdb74981f6e4339fd5808793b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:28:51 -0400 Subject: [PATCH 03/18] feat(media-store): queue resume-state and progress persistence --- lib/core/database/local_cache_database.dart | 10 +- .../data/media_transfer_queue_repository.dart | 47 +++++++++- .../media_transfer_queue_repository_test.dart | 91 +++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/lib/core/database/local_cache_database.dart b/lib/core/database/local_cache_database.dart index ba144e3ad..42e7c4013 100644 --- a/lib/core/database/local_cache_database.dart +++ b/lib/core/database/local_cache_database.dart @@ -30,6 +30,9 @@ class MediaTransferQueue extends Table { TextColumn get resumeStateJson => text().nullable()(); TextColumn get errorMessage => text().nullable()(); IntColumn get priority => integer().withDefault(const Constant(0))(); + // Transfer progress (v3), surfaced in the Transfers view. + IntColumn get progressBytes => integer().nullable()(); + IntColumn get totalBytes => integer().nullable()(); IntColumn get createdAt => integer()(); IntColumn get updatedAt => integer()(); } @@ -52,15 +55,20 @@ class LocalCacheDatabase extends _$LocalCacheDatabase { LocalCacheDatabase(super.e); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( onUpgrade: (m, from, to) async { if (from < 2) { + // Creates the tables with the CURRENT schema, columns included. await m.createTable(mediaTransferQueue); await m.createTable(mediaCacheEntries); } + if (from >= 2 && from < 3) { + await m.addColumn(mediaTransferQueue, mediaTransferQueue.progressBytes); + await m.addColumn(mediaTransferQueue, mediaTransferQueue.totalBytes); + } }, ); } diff --git a/lib/features/media_store/data/media_transfer_queue_repository.dart b/lib/features/media_store/data/media_transfer_queue_repository.dart index dc0c84a13..323903093 100644 --- a/lib/features/media_store/data/media_transfer_queue_repository.dart +++ b/lib/features/media_store/data/media_transfer_queue_repository.dart @@ -65,7 +65,52 @@ class MediaTransferQueueRepository { Future markTransferring(int id) => _setState(id, 'transferring'); - Future markDone(int id) => _setState(id, 'done'); + /// Completion also clears resume/progress state: a finished transfer + /// must not leak a stale resume point into a future re-enqueue of the + /// same media. + Future markDone(int id) async { + await (_db.update( + _db.mediaTransferQueue, + )..where((t) => t.id.equals(id))).write( + MediaTransferQueueCompanion( + state: const Value('done'), + resumeStateJson: const Value(null), + progressBytes: const Value(null), + totalBytes: const Value(null), + updatedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + + /// Persists (or clears, with null) the adapter's opaque resume point. + /// markFailed, retry, and defer all PRESERVE it - resuming after a + /// failure is the entire point. + Future updateResumeState(int id, String? resumeStateJson) async { + await (_db.update( + _db.mediaTransferQueue, + )..where((t) => t.id.equals(id))).write( + MediaTransferQueueCompanion( + resumeStateJson: Value(resumeStateJson), + updatedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + + Future updateProgress( + int id, { + required int transferredBytes, + int? totalBytes, + }) async { + await (_db.update( + _db.mediaTransferQueue, + )..where((t) => t.id.equals(id))).write( + MediaTransferQueueCompanion( + progressBytes: Value(transferredBytes), + totalBytes: Value(totalBytes), + updatedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } Future markFailed(int id, String error) async { final row = await (_db.select( diff --git a/test/features/media_store/media_transfer_queue_repository_test.dart b/test/features/media_store/media_transfer_queue_repository_test.dart index 5efb693aa..513ab1ad6 100644 --- a/test/features/media_store/media_transfer_queue_repository_test.dart +++ b/test/features/media_store/media_transfer_queue_repository_test.dart @@ -162,4 +162,95 @@ void main() { expect(await repo.deleteDone(), 1); expect((await repo.allForTesting()).length, 1); }); + + test('v3 migration adds progress columns to an existing v2 ' + 'database', () async { + final nativeDb = NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA user_version = 2'); + rawDb.execute(''' + CREATE TABLE local_asset_cache ( + media_id TEXT NOT NULL PRIMARY KEY, + local_asset_id TEXT, + resolved_at INTEGER NOT NULL, + resolution_method TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0 + ) + '''); + rawDb.execute(''' + CREATE TABLE media_transfer_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + media_id TEXT NOT NULL, + direction TEXT NOT NULL DEFAULT 'upload', + object_kind TEXT NOT NULL DEFAULT 'original', + content_hash TEXT, + state TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER, + resume_state_json TEXT, + error_message TEXT, + priority INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + rawDb.execute(''' + CREATE TABLE media_cache_entries ( + content_hash TEXT NOT NULL, + kind TEXT NOT NULL, + relative_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + last_accessed_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (content_hash, kind) + ) + '''); + rawDb.execute( + "INSERT INTO media_transfer_queue " + "(media_id, created_at, updated_at) VALUES ('m1', 1, 1)", + ); + }, + ); + final upgraded = LocalCacheDatabase(nativeDb); + addTearDown(upgraded.close); + + final cols = await upgraded + .customSelect("PRAGMA table_info('media_transfer_queue')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + expect(names, containsAll(['progress_bytes', 'total_bytes'])); + final kept = await upgraded + .customSelect("SELECT media_id FROM media_transfer_queue") + .getSingle(); + expect(kept.data['media_id'], 'm1'); + }); + + test('resume state persists through markFailed and retry, and clears on ' + 'markDone', () async { + final id = await repo.enqueueUpload(mediaId: 'm1'); + await repo.updateResumeState(id, '{"uploadId":"u1"}'); + await repo.updateProgress(id, transferredBytes: 16, totalBytes: 64); + + await repo.markFailed(id, 'network blip'); + var row = (await repo.allForTesting()).single; + expect(row.resumeStateJson, '{"uploadId":"u1"}'); + expect(row.progressBytes, 16); + + for (var i = 0; i < 4; i++) { + await repo.markFailed(id, 'boom'); + } + await repo.retry(id); + row = (await repo.allForTesting()).single; + expect( + row.resumeStateJson, + '{"uploadId":"u1"}', + reason: 'retry must keep the resume point', + ); + + await repo.markDone(id); + row = (await repo.allForTesting()).single; + expect(row.resumeStateJson, isNull); + expect(row.progressBytes, isNull); + expect(row.totalBytes, isNull); + }); } From 4c57d6ce27f84aa616448aa74f64328416f5c1b0 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:35:10 -0400 Subject: [PATCH 04/18] feat(media-store): S3 multipart upload with resume and streamed download --- .../cloud_storage/s3/s3_api_client.dart | 167 +++++++++++++- .../media_store/s3_media_object_store.dart | 215 ++++++++++++++++-- .../cloud_storage/s3/s3_multipart_test.dart | 85 +++++++ .../s3_storage_provider_test.dart | 6 +- .../s3_media_object_store_test.dart | 177 ++++++++------ .../presentation/s3_config_page_test.dart | 5 +- test/helpers/fake_s3_server.dart | 168 ++++++++++++++ 7 files changed, 736 insertions(+), 87 deletions(-) create mode 100644 test/core/services/cloud_storage/s3/s3_multipart_test.dart create mode 100644 test/helpers/fake_s3_server.dart diff --git a/lib/core/services/cloud_storage/s3/s3_api_client.dart b/lib/core/services/cloud_storage/s3/s3_api_client.dart index 38b4a0f60..0a30bec4b 100644 --- a/lib/core/services/cloud_storage/s3/s3_api_client.dart +++ b/lib/core/services/cloud_storage/s3/s3_api_client.dart @@ -23,6 +23,15 @@ class S3ObjectInfo { }); } +/// One uploaded part of a multipart session, as needed for completion +/// and resume validation. +class S3PartInfo { + final int partNumber; + final String etag; + + const S3PartInfo({required this.partNumber, required this.etag}); +} + /// Minimal S3 REST client: the five operations the sync backend needs, /// signed with SigV4. Throws [CloudStorageException] for every failure so /// callers never see raw HTTP details. The secret key and Authorization @@ -162,6 +171,144 @@ class S3ApiClient { } } + /// Starts a multipart upload session; returns the server's uploadId. + Future createMultipartUpload( + String key, { + required String contentType, + }) async { + final response = await _sendWithRetry( + 'POST', + key, + queryParams: {'uploads': ''}, + ); + if (response.statusCode != 200) _throwFor('start upload', key, response); + final uploadId = _xmlElementText(response.body, 'UploadId'); + if (uploadId == null || uploadId.isEmpty) { + throw CloudStorageException('S3 returned no UploadId for "$key"'); + } + return uploadId; + } + + /// Uploads one part; returns its ETag for the completion manifest. + Future uploadPart( + String key, { + required String uploadId, + required int partNumber, + required Uint8List bytes, + }) async { + final response = await _sendWithRetry( + 'PUT', + key, + queryParams: {'partNumber': '$partNumber', 'uploadId': uploadId}, + body: bytes, + ); + if (response.statusCode != 200) { + _throwFor('upload part $partNumber of', key, response); + } + final etag = response.headers['etag']; + if (etag == null || etag.isEmpty) { + throw CloudStorageException( + 'S3 returned no ETag for part $partNumber of "$key"', + ); + } + return etag; + } + + Future completeMultipartUpload( + String key, { + required String uploadId, + required List parts, + }) async { + final manifest = StringBuffer(''); + for (final part in parts) { + manifest.write( + '${part.partNumber}' + '${part.etag}', + ); + } + manifest.write(''); + final response = await _sendWithRetry( + 'POST', + key, + queryParams: {'uploadId': uploadId}, + body: Uint8List.fromList(utf8.encode(manifest.toString())), + ); + if (response.statusCode != 200) _throwFor('finish upload', key, response); + // S3 reports completion errors inside a 200 body. + if (_xmlElementText(response.body, 'Code') != null) { + throw CloudStorageException( + 'S3 rejected the upload completion for "$key"', + ); + } + } + + /// Idempotent: aborting an unknown session succeeds. + Future abortMultipartUpload( + String key, { + required String uploadId, + }) async { + final response = await _sendWithRetry( + 'DELETE', + key, + queryParams: {'uploadId': uploadId}, + ); + const okStatuses = {200, 204, 404}; + if (!okStatuses.contains(response.statusCode)) { + _throwFor('abort upload', key, response); + } + } + + Future> listParts( + String key, { + required String uploadId, + }) async { + final response = await _sendWithRetry( + 'GET', + key, + queryParams: {'uploadId': uploadId}, + ); + if (response.statusCode != 200) _throwFor('list parts of', key, response); + try { + final document = XmlDocument.parse(response.body); + return document + .findAllElements('Part') + .map( + (part) => S3PartInfo( + partNumber: int.parse(part.getElement('PartNumber')!.innerText), + etag: part.getElement('ETag')!.innerText, + ), + ) + .toList(); + } on Exception catch (e) { + throw CloudStorageException('S3 returned an unreadable part list', e); + } + } + + /// Byte-range read: [start]..[endInclusive]. Returns the slice and the + /// object's total length parsed from Content-Range. + Future<({Uint8List bytes, int totalLength})> getObjectRange( + String key, { + required int start, + required int endInclusive, + }) async { + final response = await _sendWithRetry( + 'GET', + key, + extraHeaders: {'range': 'bytes=$start-$endInclusive'}, + ); + if (response.statusCode == 404) { + throw CloudStorageException('File not found in S3: $key'); + } + if (response.statusCode != 206 && response.statusCode != 200) { + _throwFor('download range of', key, response); + } + final contentRange = response.headers['content-range']; + final total = contentRange == null + ? response.bodyBytes.length + : int.parse(contentRange.split('/').last); + return (bytes: response.bodyBytes, totalLength: total); + } + /// Closes the underlying HTTP client (including an injected one). void close() => _http.close(); @@ -199,6 +346,7 @@ class S3ApiClient { String key, { Map queryParams = const {}, Uint8List? body, + Map extraHeaders = const {}, }) async { try { var response = await _send( @@ -206,6 +354,7 @@ class S3ApiClient { key, queryParams: queryParams, body: body, + extraHeaders: extraHeaders, ); if (response.statusCode >= 300) { final corrected = await _replayWithRegionHint( @@ -214,6 +363,7 @@ class S3ApiClient { key, queryParams, body, + extraHeaders, ); // The replay flows through the normal 5xx retry below, which now // signs with the corrected region. @@ -232,7 +382,7 @@ class S3ApiClient { } on TimeoutException { // Timed out; retry once below. } - return _retry(method, key, queryParams, body); + return _retry(method, key, queryParams, body, extraHeaders); } Future _retry( @@ -240,10 +390,17 @@ class S3ApiClient { String key, Map queryParams, Uint8List? body, + Map extraHeaders, ) async { await Future.delayed(_retryDelay); try { - return await _send(method, key, queryParams: queryParams, body: body); + return await _send( + method, + key, + queryParams: queryParams, + body: body, + extraHeaders: extraHeaders, + ); } on Exception catch (e) { throw CloudStorageException( 'Could not reach S3 endpoint ${_config.displayHost}', @@ -264,6 +421,7 @@ class S3ApiClient { String key, Map queryParams, Uint8List? body, + Map extraHeaders, ) async { final hint = _regionHint(response); if (hint == null || hint == _region) return null; @@ -273,6 +431,7 @@ class S3ApiClient { key, queryParams: queryParams, body: body, + extraHeaders: extraHeaders, ); if (replay.statusCode < 300) onRegionCorrected?.call(hint); return replay; @@ -317,6 +476,7 @@ class S3ApiClient { String key, { Map queryParams = const {}, Uint8List? body, + Map extraHeaders = const {}, }) async { final target = _target(key); final authority = target.port == null @@ -344,6 +504,9 @@ class S3ApiClient { headers.remove('host'); final request = http.Request(method, uri)..headers.addAll(headers); + // Unsigned extras (e.g. Range): only signed headers participate in the + // SigV4 signature, so S3 accepts these alongside it. + request.headers.addAll(extraHeaders); if (body != null) request.bodyBytes = body; return http.Response.fromStream(await _http.send(request)); } diff --git a/lib/core/services/media_store/s3_media_object_store.dart b/lib/core/services/media_store/s3_media_object_store.dart index 6ada93b4b..02f40d781 100644 --- a/lib/core/services/media_store/s3_media_object_store.dart +++ b/lib/core/services/media_store/s3_media_object_store.dart @@ -1,24 +1,38 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_api_client.dart'; import 'package:submersion/core/services/media_store/media_object_store.dart'; -/// S3-backed media object store (Phase 1: single-shot transfers). +/// S3-backed media object store. /// /// Wire keys are '$keyPrefix$key' -- S3ApiClient._target does NOT apply the -/// configured prefix, so this adapter must. Whole-byte transfers are -/// acceptable for photos; Phase 3 replaces the internals with multipart + -/// Range streaming for video without changing the interface. +/// configured prefix, so this adapter must. Objects at or below +/// [partSizeBytes] use single-shot transfers; larger objects go through S3 +/// multipart with per-part resume state (design spec section 8.2), and +/// downloads above [downloadChunkBytes] stream Range chunks to disk so +/// memory stays bounded for arbitrarily large media. class S3MediaObjectStore implements MediaObjectStore { - S3MediaObjectStore({required S3ApiClient client, required String keyPrefix}) - : _client = client, - _keyPrefix = keyPrefix; + S3MediaObjectStore({ + required S3ApiClient client, + required String keyPrefix, + this.partSizeBytes = 8 * 1024 * 1024, + this.downloadChunkBytes = 8 * 1024 * 1024, + }) : _client = client, + _keyPrefix = keyPrefix; final S3ApiClient _client; final String _keyPrefix; + /// Multipart part size, and the single-shot/multipart threshold. + final int partSizeBytes; + + /// Range-GET chunk size, and the whole-body/chunked download threshold. + final int downloadChunkBytes; + String _wire(String key) => '$_keyPrefix$key'; @override @@ -45,9 +59,9 @@ class S3MediaObjectStore implements MediaObjectStore { String? resumeStateJson, void Function(String resumeStateJson)? onResumeStateChanged, }) async { - final Uint8List bytes; + final int length; try { - bytes = await source.readAsBytes(); + length = await source.length(); } on FileSystemException catch (e) { throw MediaStoreException( 'cannot read source for $key', @@ -55,14 +69,161 @@ class S3MediaObjectStore implements MediaObjectStore { cause: e, ); } + if (length <= partSizeBytes) { + final Uint8List bytes; + try { + bytes = await source.readAsBytes(); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + try { + await _client.putObject(_wire(key), bytes); + onProgress?.call(bytes.length, bytes.length); + } on CloudStorageException catch (e) { + throw _map('put', key, e); + } + return; + } + await _putMultipart( + key, + source, + length, + contentType: contentType, + onProgress: onProgress, + resumeStateJson: resumeStateJson, + onResumeStateChanged: onResumeStateChanged, + ); + } + + Future _putMultipart( + String key, + File source, + int length, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }) async { + final wireKey = _wire(key); try { - await _client.putObject(_wire(key), bytes); - onProgress?.call(bytes.length, bytes.length); + var session = await _validateResume(wireKey, resumeStateJson); + session ??= ( + uploadId: await _client.createMultipartUpload( + wireKey, + contentType: contentType, + ), + parts: [], + ); + final parts = [...session.parts]; + final totalParts = (length + partSizeBytes - 1) ~/ partSizeBytes; + var bytesSent = parts.length * partSizeBytes; + + final raf = await source.open(); + try { + for (var n = parts.length + 1; n <= totalParts; n++) { + final offset = (n - 1) * partSizeBytes; + await raf.setPosition(offset); + final chunk = await raf.read(min(partSizeBytes, length - offset)); + final etag = await _client.uploadPart( + wireKey, + uploadId: session.uploadId, + partNumber: n, + bytes: chunk, + ); + parts.add(S3PartInfo(partNumber: n, etag: etag)); + bytesSent = min(bytesSent + partSizeBytes, length); + onResumeStateChanged?.call(_resumeToJson(session.uploadId, parts)); + onProgress?.call(bytesSent, length); + } + } finally { + await raf.close(); + } + + await _client.completeMultipartUpload( + wireKey, + uploadId: session.uploadId, + parts: parts, + ); } on CloudStorageException catch (e) { throw _map('put', key, e); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); } } + /// Parses and server-verifies a previous attempt's resume state. Returns + /// null (after a best-effort abort) when the state is absent, malformed, + /// sized differently, or no longer matches the server's part list. + Future<({String uploadId, List parts})?> _validateResume( + String wireKey, + String? resumeStateJson, + ) async { + if (resumeStateJson == null) return null; + String? uploadId; + try { + final decoded = jsonDecode(resumeStateJson); + if (decoded is! Map) return null; + uploadId = decoded['uploadId'] as String?; + final recordedPartSize = (decoded['partSizeBytes'] as num?)?.toInt(); + final rawParts = decoded['parts']; + if (uploadId == null || + recordedPartSize != partSizeBytes || + rawParts is! List) { + await _abortQuietly(wireKey, uploadId); + return null; + } + final recorded = [ + for (final raw in rawParts.cast>()) + S3PartInfo( + partNumber: (raw['n'] as num).toInt(), + etag: raw['etag'] as String, + ), + ]; + final serverParts = await _client.listParts(wireKey, uploadId: uploadId); + final serverByNumber = { + for (final part in serverParts) part.partNumber: part.etag, + }; + final consistent = recorded.every( + (part) => serverByNumber[part.partNumber] == part.etag, + ); + if (!consistent) { + await _abortQuietly(wireKey, uploadId); + return null; + } + return (uploadId: uploadId, parts: recorded); + } on Exception { + // Malformed JSON, unknown uploadId, or listParts failure: the resume + // point is unusable; start over. + await _abortQuietly(wireKey, uploadId); + return null; + } + } + + Future _abortQuietly(String wireKey, String? uploadId) async { + if (uploadId == null) return; + try { + await _client.abortMultipartUpload(wireKey, uploadId: uploadId); + } on Exception { + // Best-effort: an unaborted stray session expires server-side. + } + } + + String _resumeToJson(String uploadId, List parts) => jsonEncode({ + 'uploadId': uploadId, + 'partSizeBytes': partSizeBytes, + 'parts': [ + for (final part in parts) {'n': part.partNumber, 'etag': part.etag}, + ], + }); + @override Future getFile( String key, @@ -70,9 +231,35 @@ class S3MediaObjectStore implements MediaObjectStore { TransferProgressCallback? onProgress, }) async { try { - final bytes = await _client.getObject(_wire(key)); - await destination.writeAsBytes(bytes, flush: true); - onProgress?.call(bytes.length, bytes.length); + final info = await _client.headObject(_wire(key)); + if (info == null) { + throw CloudStorageException('File not found in S3: $key'); + } + final total = info.size; + if (total == null || total <= downloadChunkBytes) { + final bytes = await _client.getObject(_wire(key)); + await destination.writeAsBytes(bytes, flush: true); + onProgress?.call(bytes.length, bytes.length); + return; + } + final raf = await destination.open(mode: FileMode.write); + try { + var received = 0; + while (received < total) { + final end = min(received + downloadChunkBytes, total) - 1; + final range = await _client.getObjectRange( + _wire(key), + start: received, + endInclusive: end, + ); + await raf.writeFrom(range.bytes); + received += range.bytes.length; + onProgress?.call(received, total); + } + await raf.flush(); + } finally { + await raf.close(); + } } on CloudStorageException catch (e) { throw _map('get', key, e); } diff --git a/test/core/services/cloud_storage/s3/s3_multipart_test.dart b/test/core/services/cloud_storage/s3/s3_multipart_test.dart new file mode 100644 index 000000000..d953d6879 --- /dev/null +++ b/test/core/services/cloud_storage/s3/s3_multipart_test.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/cloud_storage/s3/s3_api_client.dart'; +import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; + +import '../../../../helpers/fake_s3_server.dart'; + +void main() { + late FakeS3Server server; + late S3ApiClient client; + + setUp(() { + server = FakeS3Server(); + client = S3ApiClient( + S3Config( + endpoint: 'http://localhost:9000', + bucket: 'test-bucket', + prefix: '', + accessKeyId: 'AK', + secretAccessKey: 'SK', + ), + httpClient: server.client, + retryDelay: const Duration(milliseconds: 1), + ); + }); + + test('multipart create, upload, list, complete round-trips bytes', () async { + final uploadId = await client.createMultipartUpload( + 'big.bin', + contentType: 'application/octet-stream', + ); + expect(uploadId, isNotEmpty); + + final part1 = Uint8List.fromList(List.filled(10, 1)); + final part2 = Uint8List.fromList(List.filled(6, 2)); + final etag1 = await client.uploadPart( + 'big.bin', + uploadId: uploadId, + partNumber: 1, + bytes: part1, + ); + final etag2 = await client.uploadPart( + 'big.bin', + uploadId: uploadId, + partNumber: 2, + bytes: part2, + ); + + final listed = await client.listParts('big.bin', uploadId: uploadId); + expect(listed.map((p) => p.partNumber).toList(), [1, 2]); + expect(listed.map((p) => p.etag).toList(), [etag1, etag2]); + + await client.completeMultipartUpload( + 'big.bin', + uploadId: uploadId, + parts: [ + S3PartInfo(partNumber: 1, etag: etag1), + S3PartInfo(partNumber: 2, etag: etag2), + ], + ); + expect(server.objects['big.bin'], [...part1, ...part2]); + }); + + test('abort discards the session and is idempotent', () async { + final uploadId = await client.createMultipartUpload( + 'gone.bin', + contentType: 'application/octet-stream', + ); + await client.abortMultipartUpload('gone.bin', uploadId: uploadId); + await client.abortMultipartUpload('gone.bin', uploadId: uploadId); + expect(server.objects.containsKey('gone.bin'), isFalse); + }); + + test('getObjectRange returns the slice and the total length', () async { + server.objects['r.bin'] = Uint8List.fromList(List.generate(100, (i) => i)); + final range = await client.getObjectRange( + 'r.bin', + start: 10, + endInclusive: 19, + ); + expect(range.bytes, List.generate(10, (i) => i + 10)); + expect(range.totalLength, 100); + }); +} diff --git a/test/core/services/cloud_storage/s3_storage_provider_test.dart b/test/core/services/cloud_storage/s3_storage_provider_test.dart index 3909a9a95..92f00b585 100644 --- a/test/core/services/cloud_storage/s3_storage_provider_test.dart +++ b/test/core/services/cloud_storage/s3_storage_provider_test.dart @@ -21,8 +21,10 @@ class _MemoryCredentialsStore implements S3CredentialsStore { Future clear() async => stored = null; } -/// Records calls; serves canned objects. -class _FakeS3ApiClient implements S3ApiClient { +/// Records calls; serves canned objects. Extends Fake so client additions +/// (e.g. the multipart operations) never break this double: unstubbed +/// members throw only if actually called. +class _FakeS3ApiClient extends Fake implements S3ApiClient { _FakeS3ApiClient(this.config); final S3Config config; diff --git a/test/core/services/media_store/s3_media_object_store_test.dart b/test/core/services/media_store/s3_media_object_store_test.dart index df2d28a4a..8e8c052c2 100644 --- a/test/core/services/media_store/s3_media_object_store_test.dart +++ b/test/core/services/media_store/s3_media_object_store_test.dart @@ -1,88 +1,39 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_api_client.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; import 'package:submersion/core/services/media_store/media_object_store.dart'; import 'package:submersion/core/services/media_store/s3_media_object_store.dart'; +import '../../../helpers/fake_s3_server.dart'; + void main() { late Directory tmp; - final captured = []; - final remote = {}; // wire key -> bytes + late FakeS3Server server; - S3MediaObjectStore build({String prefix = 'submersion-media/'}) { + S3MediaObjectStore build({int? partSizeBytes, int? downloadChunkBytes}) { final config = S3Config( endpoint: 'http://localhost:9000', bucket: 'test-bucket', - prefix: prefix, + prefix: 'submersion-media/', accessKeyId: 'AKIA_TEST', secretAccessKey: 'secret', ); - final client = S3ApiClient( - config, - httpClient: MockClient((request) async { - captured.add(request); - // Path-style addressing: /test-bucket/ - final key = Uri.decodeComponent( - request.url.path.replaceFirst('/test-bucket/', ''), - ); - switch (request.method) { - case 'PUT': - remote[key] = Uint8List.fromList(request.bodyBytes); - return http.Response('', 200); - case 'HEAD': - final body = remote[key]; - if (body == null) return http.Response('', 404); - return http.Response( - '', - 200, - headers: { - 'content-length': '${body.length}', - 'last-modified': 'Thu, 09 Jul 2026 00:00:00 GMT', - }, - ); - case 'GET': - if (request.url.queryParameters.containsKey('list-type')) { - final prefixParam = request.url.queryParameters['prefix'] ?? ''; - final keys = remote.keys - .where((k) => k.startsWith(prefixParam)) - .toList(); - final contents = keys - .map( - (k) => - '$k' - '2026-07-09T00:00:00.000Z' - '${remote[k]!.length}', - ) - .join(); - return http.Response( - '' - 'false$contents' - '', - 200, - ); - } - final body = remote[key]; - if (body == null) return http.Response('', 404); - return http.Response.bytes(body, 200); - case 'DELETE': - remote.remove(key); - return http.Response('', 204); - default: - return http.Response('', 500); - } - }), + return S3MediaObjectStore( + client: S3ApiClient( + config, + httpClient: server.client, + retryDelay: const Duration(milliseconds: 1), + ), + keyPrefix: config.prefix, + partSizeBytes: partSizeBytes ?? 8 * 1024 * 1024, + downloadChunkBytes: downloadChunkBytes ?? 8 * 1024 * 1024, ); - return S3MediaObjectStore(client: client, keyPrefix: config.prefix); } setUp(() async { - captured.clear(); - remote.clear(); + server = FakeS3Server(); tmp = await Directory.systemTemp.createTemp('s3_mos_test'); }); @@ -96,8 +47,8 @@ void main() { src, contentType: 'image/jpeg', ); - expect(remote.keys, ['submersion-media/smv1/objects/ab/abc.jpg']); - expect(remote.values.single, [1, 2, 3]); + expect(server.objects.keys, ['submersion-media/smv1/objects/ab/abc.jpg']); + expect(server.objects.values.single, [1, 2, 3]); }); test('head returns size and null for missing; getFile round-trips and ' @@ -147,6 +98,98 @@ void main() { final store = build(); await store.delete('smv1/objects/aa/gone.bin'); await store.delete('smv1/objects/aa/gone.bin'); - expect(remote, isEmpty); + expect(server.objects, isEmpty); + }); + + test('large putFile goes multipart, reports progress, and ' + 'round-trips', () async { + final store = build( + partSizeBytes: 64 * 1024, + downloadChunkBytes: 64 * 1024, + ); + final bytes = List.generate(200 * 1024, (i) => i % 251); + final src = File('${tmp.path}/video.mp4')..writeAsBytesSync(bytes); + + final progress = []; + String? resumeJson; + await store.putFile( + 'smv1/objects/aa/video.mp4', + src, + contentType: 'video/mp4', + onProgress: (sent, total) => progress.add(sent), + onResumeStateChanged: (json) => resumeJson = json, + ); + + expect(server.objects['submersion-media/smv1/objects/aa/video.mp4'], bytes); + expect(server.partUploadCount, 4, reason: '200KiB / 64KiB = 4 parts'); + expect(progress.last, bytes.length); + expect(resumeJson, contains('"uploadId"')); + + final dest = File('${tmp.path}/video.out'); + final getProgress = []; + await store.getFile( + 'smv1/objects/aa/video.mp4', + dest, + onProgress: (received, total) => getProgress.add(received), + ); + expect(await dest.readAsBytes(), bytes); + expect(getProgress.length, greaterThan(1), reason: 'chunked download'); + expect(getProgress.last, bytes.length); + }); + + test('kill-and-resume: a mid-upload failure resumes from the last ' + 'acknowledged part without re-uploading it', () async { + final store = build(partSizeBytes: 64 * 1024); + final bytes = List.generate(200 * 1024, (i) => (i * 3) % 251); + final src = File('${tmp.path}/kr.mp4')..writeAsBytesSync(bytes); + + String? resumeJson; + server.failAfterPartUploads = 2; // parts 1-2 succeed, part 3 dies + await expectLater( + store.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + onResumeStateChanged: (json) => resumeJson = json, + ), + throwsA(isA()), + ); + expect(resumeJson, isNotNull); + final partsBefore = server.partUploadCount; + expect(partsBefore, 2); + + // "Restart the app": a fresh store instance resumes from the JSON. + server.failAfterPartUploads = null; + final resumed = build(partSizeBytes: 64 * 1024); + await resumed.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: resumeJson, + onResumeStateChanged: (json) => resumeJson = json, + ); + expect(server.objects['submersion-media/smv1/objects/aa/kr.mp4'], bytes); + expect( + server.partUploadCount - partsBefore, + 2, + reason: 'only parts 3-4 upload on resume', + ); + }); + + test('a stale resume state (unknown uploadId) aborts and restarts ' + 'fresh', () async { + final store = build(partSizeBytes: 64 * 1024); + final bytes = List.generate(130 * 1024, (i) => i % 199); + final src = File('${tmp.path}/stale.mp4')..writeAsBytesSync(bytes); + + await store.putFile( + 'smv1/objects/aa/stale.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: + '{"uploadId":"upload-does-not-exist","partSizeBytes":65536,' + '"parts":[{"n":1,"etag":"\\"bogus\\""}]}', + ); + expect(server.objects['submersion-media/smv1/objects/aa/stale.mp4'], bytes); }); } diff --git a/test/features/settings/presentation/s3_config_page_test.dart b/test/features/settings/presentation/s3_config_page_test.dart index 3490f17e1..15926edf4 100644 --- a/test/features/settings/presentation/s3_config_page_test.dart +++ b/test/features/settings/presentation/s3_config_page_test.dart @@ -39,8 +39,9 @@ class _MemoryCredentialsStore implements S3CredentialsStore { // Stores objects written via putObject and serves them via getObject. // Throws CloudStorageException for keys that have never been put, mirroring -// the real client's 404 path. -class _FakeS3ApiClient implements S3ApiClient { +// the real client's 404 path. Extends Fake so client additions never break +// this double: unstubbed members throw only if actually called. +class _FakeS3ApiClient extends Fake implements S3ApiClient { final List calls = []; final Map _objects = {}; CloudStorageException? failListWith; diff --git a/test/helpers/fake_s3_server.dart b/test/helpers/fake_s3_server.dart new file mode 100644 index 000000000..23d085284 --- /dev/null +++ b/test/helpers/fake_s3_server.dart @@ -0,0 +1,168 @@ +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// In-memory S3 protocol fake for MockClient: single-shot object CRUD, +/// list-type=2 listing, multipart sessions (create/part/complete/abort/ +/// listParts), and Range GETs. Exercises the real SigV4 signing path. +class FakeS3Server { + FakeS3Server({this.bucket = 'test-bucket'}); + + final String bucket; + + /// Wire key (including any configured prefix) -> completed object bytes. + final Map objects = {}; + + final List captured = []; + + final Map> _sessions = {}; + int _uploadCounter = 0; + + /// Successful part uploads seen (across all sessions). + int partUploadCount = 0; + + /// When set, part-upload PUTs are rejected with 500 once + /// [partUploadCount] reaches this value, until the field is cleared. + /// (Persistent, because S3ApiClient retries a 5xx once - a one-shot + /// failure would be silently absorbed by the retry.) + int? failAfterPartUploads; + + /// One-shot failure for any request (parity with the in-memory store). + Exception? failNextWith; + + MockClient get client => MockClient(_handle); + + Future _handle(http.Request request) async { + captured.add(request); + final injected = failNextWith; + if (injected != null) { + failNextWith = null; + throw injected; + } + final key = Uri.decodeComponent( + request.url.path.replaceFirst('/$bucket/', ''), + ); + final qp = request.url.queryParameters; + switch (request.method) { + case 'POST': + if (qp.containsKey('uploads')) { + _uploadCounter++; + final id = 'upload-$_uploadCounter'; + _sessions[id] = {}; + return http.Response( + '' + '$id', + 200, + ); + } + if (qp.containsKey('uploadId')) { + final session = _sessions.remove(qp['uploadId']); + if (session == null) return http.Response('', 404); + final ordered = session.keys.toList()..sort(); + final builder = BytesBuilder(); + for (final n in ordered) { + builder.add(session[n]!); + } + objects[key] = builder.toBytes(); + return http.Response( + '' + '$key', + 200, + ); + } + return http.Response('', 400); + case 'PUT': + if (qp.containsKey('partNumber')) { + final session = _sessions[qp['uploadId']]; + if (session == null) return http.Response('', 404); + final limit = failAfterPartUploads; + if (limit != null && partUploadCount >= limit) { + return http.Response('', 500); + } + partUploadCount++; + final n = int.parse(qp['partNumber']!); + session[n] = Uint8List.fromList(request.bodyBytes); + return http.Response( + '', + 200, + headers: {'etag': '"part-$n-${request.bodyBytes.length}"'}, + ); + } + objects[key] = Uint8List.fromList(request.bodyBytes); + return http.Response('', 200); + case 'HEAD': + final body = objects[key]; + if (body == null) return http.Response('', 404); + return http.Response( + '', + 200, + headers: { + 'content-length': '${body.length}', + 'last-modified': 'Thu, 09 Jul 2026 00:00:00 GMT', + }, + ); + case 'GET': + if (qp.containsKey('uploadId')) { + final session = _sessions[qp['uploadId']]; + if (session == null) return http.Response('', 404); + final ordered = session.keys.toList()..sort(); + final parts = ordered + .map( + (n) => + '$n' + '"part-$n-${session[n]!.length}"' + '${session[n]!.length}', + ) + .join(); + return http.Response( + '$parts', + 200, + ); + } + if (qp.containsKey('list-type')) { + final prefixParam = qp['prefix'] ?? ''; + final keys = objects.keys + .where((k) => k.startsWith(prefixParam)) + .toList(); + final contents = keys + .map( + (k) => + '$k' + '2026-07-09T00:00:00.000Z' + '${objects[k]!.length}', + ) + .join(); + return http.Response( + '' + 'false$contents' + '', + 200, + ); + } + final body = objects[key]; + if (body == null) return http.Response('', 404); + final range = request.headers['range']; + if (range != null) { + final match = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(range)!; + final start = int.parse(match.group(1)!); + final end = int.parse(match.group(2)!).clamp(0, body.length - 1); + return http.Response.bytes( + body.sublist(start, end + 1), + 206, + headers: {'content-range': 'bytes $start-$end/${body.length}'}, + ); + } + return http.Response.bytes(body, 200); + case 'DELETE': + if (qp.containsKey('uploadId')) { + _sessions.remove(qp['uploadId']); + return http.Response('', 204); + } + objects.remove(key); + return http.Response('', 204); + default: + return http.Response('', 500); + } + } +} From 18d70d99c1f0f7b17bb65205cb95064ad70572c1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:37:48 -0400 Subject: [PATCH 05/18] feat(media-store): video uploads with resume and progress wiring --- .../data/media_upload_pipeline.dart | 17 +++- .../media_upload_pipeline_test.dart | 79 +++++++++++++++++-- .../helpers/in_memory_media_object_store.dart | 10 +++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/lib/features/media_store/data/media_upload_pipeline.dart b/lib/features/media_store/data/media_upload_pipeline.dart index 0a8b8de95..77b34de7f 100644 --- a/lib/features/media_store/data/media_upload_pipeline.dart +++ b/lib/features/media_store/data/media_upload_pipeline.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:submersion/core/services/logger_service.dart'; @@ -109,10 +110,23 @@ class MediaUploadPipeline { final key = StoreKeys.objectKey(digest.hash, extension: extension); final existing = await _store.head(key); if (existing == null) { + // Resume state survives failures on the queue row; content + // addressing makes replaying it against a re-materialized staging + // copy safe (identical bytes, identical part boundaries). await _store.putFile( key, staged, contentType: StoreKeys.contentTypeFor(extension), + resumeStateJson: entry.resumeStateJson, + onResumeStateChanged: (json) => + unawaited(_queue.updateResumeState(entry.id, json)), + onProgress: (sent, total) => unawaited( + _queue.updateProgress( + entry.id, + transferredBytes: sent, + totalBytes: total ?? digest.sizeBytes, + ), + ), ); } @@ -139,9 +153,6 @@ class MediaUploadPipeline { bool _isEligible(MediaItem item) { if (!_eligibleSources.contains(item.sourceType)) return false; if (item.mediaType == MediaType.instructorSignature) return false; - // Videos wait for Phase 3's multipart transfer; the Phase 1 single-shot - // path would read a whole video into memory. - if (item.mediaType == MediaType.video) return false; final resolver = _registry.resolverFor(item.sourceType); return resolver.canResolveOnThisDevice(item); } diff --git a/test/features/media_store/media_upload_pipeline_test.dart b/test/features/media_store/media_upload_pipeline_test.dart index f3c75e510..fecf4e82f 100644 --- a/test/features/media_store/media_upload_pipeline_test.dart +++ b/test/features/media_store/media_upload_pipeline_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'dart:io'; import 'dart:ui' show Size; @@ -28,6 +29,10 @@ class _FakeLocalFileResolver implements MediaSourceResolver { MediaSourceData data; + /// When set, resolveThumbnail serves this instead of [data] (models the + /// gallery resolver's pre-compressed poster bytes for videos). + MediaSourceData? thumbnailData; + @override MediaSourceType get sourceType => MediaSourceType.localFile; @@ -41,7 +46,7 @@ class _FakeLocalFileResolver implements MediaSourceResolver { Future resolveThumbnail( domain.MediaItem item, { required Size target, - }) async => data; + }) async => thumbnailData ?? data; @override Future extractMetadata(domain.MediaItem item) async => @@ -93,6 +98,11 @@ void main() { await tearDownTestDatabase(); }); + List pngBytes() => base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpe' + 'qz8AAAAASUVORK5CYII=', + ); + Future fixture(List bytes, String name) async { final f = File('${root.path}/$name'); await f.writeAsBytes(bytes, flush: true); @@ -103,9 +113,11 @@ void main() { required List bytes, required String name, domain.MediaType mediaType = domain.MediaType.photo, + MediaSourceData? thumbnailData, }) async { final file = await fixture(bytes, name); resolver.data = FileData(file: file); + resolver.thumbnailData = thumbnailData; final created = await mediaRepository.createMedia( domain.MediaItem( id: '', @@ -220,15 +232,70 @@ void main() { expect(fakeStore.objects, hasLength(1)); }); - test('video rows are ineligible until Phase 3', () async { - await enqueueLocalFileItem( - bytes: [3, 3], + test('video rows upload with contentType video/mp4', () async { + final id = await enqueueLocalFileItem( + bytes: List.generate(1024, (i) => i % 251), name: 'clip.mp4', mediaType: domain.MediaType.video, ); final entry = (await queue.nextPending(DateTime.now()))!; - expect(await pipeline.process(entry), UploadOutcome.skippedIneligible); - expect(fakeStore.objects, isEmpty); + expect(await pipeline.process(entry), UploadOutcome.uploaded); + final item = (await mediaRepository.getMediaById(id))!; + expect(item.remoteUploadedAt, isNotNull); + final key = + 'smv1/objects/${item.contentHash!.substring(0, 2)}/' + '${item.contentHash}.mp4'; + expect(fakeStore.objects.containsKey(key), isTrue); + }); + + test('resume state and progress flow through the queue row', () async { + final id = await enqueueLocalFileItem(bytes: [1, 2, 3, 4], name: 'r.jpg'); + final entry = (await queue.nextPending(DateTime.now()))!; + fakeStore.emitResumeState = '{"fake":1}'; + expect(await pipeline.process(entry), UploadOutcome.uploaded); + + expect( + fakeStore.lastResumeStateJsonIn, + isNull, + reason: 'first attempt starts with no resume state', + ); + final row = (await queue.allForTesting()).single; + expect(row.state, 'done'); + expect(row.resumeStateJson, isNull, reason: 'markDone clears it'); + + // A row carrying a pre-seeded resume state hands it to the store. + fakeStore.emitResumeState = null; + final id2 = await enqueueLocalFileItem(bytes: [9, 9], name: 'r2.jpg'); + final entry2 = (await queue.nextPending(DateTime.now()))!; + await queue.updateResumeState(entry2.id, '{"seeded":true}'); + final refreshed = (await queue.nextPending(DateTime.now()))!; + expect(await pipeline.process(refreshed), UploadOutcome.uploaded); + expect(fakeStore.lastResumeStateJsonIn, '{"seeded":true}'); + expect( + (await mediaRepository.getMediaById(id2))!.remoteUploadedAt, + isNotNull, + ); + expect(id, isNot(id2)); + }); + + test('gallery-style video thumbs upload (BytesData poster)', () async { + final id = await enqueueLocalFileItem( + bytes: List.generate(2048, (i) => i % 251), + name: 'dive.mp4', + mediaType: domain.MediaType.video, + thumbnailData: BytesData(bytes: Uint8List.fromList(pngBytes())), + ); + final entry = (await queue.nextPending(DateTime.now()))!; + expect(await pipeline.process(entry), UploadOutcome.uploaded); + final item = (await mediaRepository.getMediaById(id))!; + expect(item.remoteThumbUploadedAt, isNotNull); + expect( + fakeStore.objects.containsKey( + 'smv1/thumbs/${item.contentHash!.substring(0, 2)}/' + '${item.contentHash}.jpg', + ), + isTrue, + ); }); test('signature rows are ineligible and complete without store ' diff --git a/test/helpers/in_memory_media_object_store.dart b/test/helpers/in_memory_media_object_store.dart index f5ba1d5a8..0c5e6f6e9 100644 --- a/test/helpers/in_memory_media_object_store.dart +++ b/test/helpers/in_memory_media_object_store.dart @@ -10,6 +10,13 @@ class InMemoryMediaObjectStore implements MediaObjectStore { /// When set, the next operation throws it once and clears the field. Exception? failNextWith; + /// When set, putFile fires onResumeStateChanged with this JSON once per + /// call (pipeline wiring tests). + String? emitResumeState; + + /// The resumeStateJson the last putFile call received. + String? lastResumeStateJsonIn; + void _maybeFail() { final e = failNextWith; if (e != null) { @@ -40,9 +47,12 @@ class InMemoryMediaObjectStore implements MediaObjectStore { void Function(String resumeStateJson)? onResumeStateChanged, }) async { _maybeFail(); + lastResumeStateJsonIn = resumeStateJson; final bytes = await source.readAsBytes(); objects[key] = bytes; modified[key] = DateTime.now(); + final emit = emitResumeState; + if (emit != null) onResumeStateChanged?.call(emit); onProgress?.call(bytes.length, bytes.length); } From 302baa2baa745db09d08dd5be4e45cb7cd4e67db Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:39:06 -0400 Subject: [PATCH 06/18] feat(media-store): store-backed video playback path --- .../providers/resolved_asset_providers.dart | 36 +++++-- ...esolved_file_path_store_fallback_test.dart | 102 ++++++++++++++++++ 2 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 test/features/media_store/resolved_file_path_store_fallback_test.dart diff --git a/lib/features/media/presentation/providers/resolved_asset_providers.dart b/lib/features/media/presentation/providers/resolved_asset_providers.dart index b29c427b2..97a70f96f 100644 --- a/lib/features/media/presentation/providers/resolved_asset_providers.dart +++ b/lib/features/media/presentation/providers/resolved_asset_providers.dart @@ -1,10 +1,13 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/features/media/data/repositories/local_asset_cache_repository.dart'; import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; /// Provider for the asset resolution service (singleton). final assetResolutionServiceProvider = Provider((ref) { @@ -86,18 +89,37 @@ final resolvedFullResolutionProvider = }); /// Resolved file path provider for video playback. +/// +/// Resolution order: gallery asset (when the item carries an asset id) -> +/// the item's own localPath when that file exists -> the media store +/// fallback (downloads the original into the content-addressed cache; +/// design spec section 10) -> null. final resolvedFilePathProvider = FutureProvider.family(( ref, item, ) async { - final service = ref.watch(assetResolutionServiceProvider); - final resolution = await service.resolveAssetId(item); + // Gallery fast path. + if (item.platformAssetId != null) { + final service = ref.watch(assetResolutionServiceProvider); + final resolution = await service.resolveAssetId(item); + if (resolution.status != ResolutionStatus.unavailable && + resolution.localAssetId != null) { + final pickerService = ref.watch(photoPickerServiceProvider); + final path = await pickerService.getFilePath(resolution.localAssetId!); + if (path != null) return path; + } + } - if (resolution.status == ResolutionStatus.unavailable || - resolution.localAssetId == null) { - return null; + // Device-local file (localFile source rows). + final localPath = item.localPath; + if (localPath != null && await File(localPath).exists()) { + return localPath; } - final pickerService = ref.watch(photoPickerServiceProvider); - return pickerService.getFilePath(resolution.localAssetId!); + // Media store fallback: play from the cached (or freshly downloaded) + // original. The viewer's loading state covers the download. + final runtime = await ref.read(mediaStoreRuntimeProvider.future); + final data = await runtime?.resolver.tryResolveRemote(item, thumbnail: false); + if (data is FileData) return data.file.path; + return null; }); diff --git a/test/features/media_store/resolved_file_path_store_fallback_test.dart b/test/features/media_store/resolved_file_path_store_fallback_test.dart new file mode 100644 index 000000000..a28942a2c --- /dev/null +++ b/test/features/media_store/resolved_file_path_store_fallback_test.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/core/services/media_store/store_keys.dart'; +import 'package:submersion/features/media/data/resolvers/media_store_resolver.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; +import 'package:submersion/features/media_store/data/media_cache_store.dart'; +import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; + +import '../../helpers/in_memory_media_object_store.dart'; + +void main() { + late LocalCacheDatabase db; + late Directory root; + late InMemoryMediaObjectStore store; + late ProviderContainer container; + + setUp(() async { + db = LocalCacheDatabase(NativeDatabase.memory()); + root = await Directory.systemTemp.createTemp('rfp_fallback'); + store = InMemoryMediaObjectStore(); + final cache = MediaCacheStore(database: db, root: root); + final runtime = MediaStoreRuntime( + storeId: 's1', + store: store, + cache: cache, + resolver: MediaStoreResolver(store: store, cache: cache), + ); + container = ProviderContainer( + overrides: [ + mediaStoreRuntimeProvider.overrideWith((ref) async => runtime), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await db.close(); + await root.delete(recursive: true); + }); + + MediaItem videoItem({required String hash, String? localPath}) => MediaItem( + id: 'v1', + mediaType: MediaType.video, + sourceType: MediaSourceType.localFile, + localPath: localPath, + originalFilename: 'dive.mp4', + takenAt: DateTime(2026), + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + contentHash: hash, + remoteUploadedAt: DateTime(2026, 7, 1), + ); + + test('existing localPath wins without touching the store', () async { + final local = File('${root.path}/here.mp4')..writeAsBytesSync([1, 2]); + final path = await container.read( + resolvedFilePathProvider( + videoItem(hash: 'a' * 64, localPath: local.path), + ).future, + ); + expect(path, local.path); + expect(store.objects, isEmpty); + }); + + test('dead localPath falls back to the store and returns playable ' + 'bytes', () async { + final bytes = List.generate(4096, (i) => (i * 5) % 251); + final seed = File('${root.path}/seed.mp4')..writeAsBytesSync(bytes); + final digest = await sha256OfFile(seed); + store.objects[StoreKeys.objectKey(digest.hash, extension: 'mp4')] = bytes; + + final path = await container.read( + resolvedFilePathProvider( + videoItem( + hash: digest.hash, + localPath: '/nonexistent/on/this/device.mp4', + ), + ).future, + ); + expect(path, isNotNull); + expect(await File(path!).readAsBytes(), bytes); + }); + + test('no store and no local file yields null', () async { + final bare = ProviderContainer( + overrides: [mediaStoreRuntimeProvider.overrideWith((ref) async => null)], + ); + addTearDown(bare.dispose); + final path = await bare.read( + resolvedFilePathProvider( + videoItem(hash: 'b' * 64, localPath: '/nope.mp4'), + ).future, + ); + expect(path, isNull); + }); +} From 2c8b365ba073ce77e5bce9e59489717a6bfdfb16 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:39:50 -0400 Subject: [PATCH 07/18] feat(media-store): transfer progress bars --- .../presentation/pages/transfers_page.dart | 25 ++++++++++++++++--- .../media_store/transfers_page_test.dart | 20 +++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/lib/features/media_store/presentation/pages/transfers_page.dart b/lib/features/media_store/presentation/pages/transfers_page.dart index 5d3e952d6..70378a7ca 100644 --- a/lib/features/media_store/presentation/pages/transfers_page.dart +++ b/lib/features/media_store/presentation/pages/transfers_page.dart @@ -74,9 +74,28 @@ class _TransferTile extends ConsumerWidget { : null, ), title: Text(label), - subtitle: entry.errorMessage != null - ? Text(entry.errorMessage!, maxLines: 2) - : Text(entry.mediaId, maxLines: 1), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (entry.errorMessage != null) + Text(entry.errorMessage!, maxLines: 2) + else + Text(entry.mediaId, maxLines: 1), + if (entry.state == 'transferring') + Padding( + padding: const EdgeInsets.only(top: 4), + child: LinearProgressIndicator( + value: + (entry.progressBytes != null && + entry.totalBytes != null && + entry.totalBytes! > 0) + ? entry.progressBytes! / entry.totalBytes! + : null, + ), + ), + ], + ), trailing: entry.state == 'failed' ? TextButton( onPressed: () async { diff --git a/test/features/media_store/transfers_page_test.dart b/test/features/media_store/transfers_page_test.dart index 75556ae76..b3c596c1a 100644 --- a/test/features/media_store/transfers_page_test.dart +++ b/test/features/media_store/transfers_page_test.dart @@ -87,4 +87,24 @@ void main() { final row = (await tester.runAsync(() => repo.allForTesting()))!.single; expect(row.state, 'pending'); }); + + testWidgets('transferring entries render a determinate progress bar', ( + tester, + ) async { + late List snapshot; + await tester.runAsync(() async { + final id = await repo.enqueueUpload(mediaId: 'm-v'); + await repo.markTransferring(id); + await repo.updateProgress(id, transferredBytes: 25, totalBytes: 100); + snapshot = await repo.watchEntries().first; + }); + + await tester.pumpWidget(app(snapshot)); + await tester.pump(); + + final bar = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(bar.value, 0.25); + }); } From a401baba7f0b2c39d0a85d89d57f92eb43fa7b96 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 18:43:30 -0400 Subject: [PATCH 08/18] feat(media-store): phase 3 exit coverage --- .../media_store_end_to_end_test.dart | 73 +++++++++++++++++++ .../support/fake_local_file_resolver.dart | 6 +- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/test/features/media_store/media_store_end_to_end_test.dart b/test/features/media_store/media_store_end_to_end_test.dart index 6073a8d16..dd37c543a 100644 --- a/test/features/media_store/media_store_end_to_end_test.dart +++ b/test/features/media_store/media_store_end_to_end_test.dart @@ -277,4 +277,77 @@ void main() { reason: 'grids never pull originals', ); }); + + test('a video uploaded on device A plays from the store on device ' + 'B', () async { + // Device A: localFile video with a BytesData poster thumb. + final mediaRepositoryA = MediaRepository(); + final cacheA = MediaCacheStore(database: cacheDbA, root: rootA); + final queueA = MediaTransferQueueRepository(database: cacheDbA); + final resolverA = FakeLocalFileResolver(); + final workerA = MediaStoreWorker( + queue: queueA, + pipeline: MediaUploadPipeline( + mediaRepository: mediaRepositoryA, + queue: queueA, + store: bucket, + registry: MediaSourceResolverRegistry({ + MediaSourceType.localFile: resolverA, + }), + cache: cacheA, + ), + ); + + final videoBytes = List.generate(64 * 1024, (i) => (i * 11) % 251); + final video = File('${rootA.path}/dive.mp4')..writeAsBytesSync(videoBytes); + resolverA.data = FileData(file: video); + // Gallery posters are pre-compressed bytes the generator passes through + // untouched (photo_manager emits JPEG in production; the format of the + // fixture is irrelevant to the pipeline). + final posterBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpe' + 'qz8AAAAASUVORK5CYII=', + ); + resolverA.thumbnailData = BytesData(bytes: posterBytes); + + final created = await mediaRepositoryA.createMedia( + domain.MediaItem( + id: '', + mediaType: domain.MediaType.video, + sourceType: MediaSourceType.localFile, + filePath: video.path, + localPath: video.path, + originalFilename: 'dive.mp4', + takenAt: DateTime(2026, 7, 1), + createdAt: DateTime(2026, 7, 1), + updatedAt: DateTime(2026, 7, 1), + ), + ); + await queueA.enqueueUpload(mediaId: created.id); + await workerA.drain(); + + final uploaded = (await mediaRepositoryA.getMediaById(created.id))!; + expect(uploaded.remoteUploadedAt, isNotNull); + expect(uploaded.remoteThumbUploadedAt, isNotNull); + expect(bucket.objects, hasLength(2), reason: 'thumb + original'); + + // Device B: the full video resolves byte-identical; the grid thumb is + // a JPEG poster. + final onB = uploaded.copyWith( + platformAssetId: null, + localPath: '/nonexistent/on/device-b.mp4', + ); + final cacheB = MediaCacheStore(database: cacheDbB, root: rootB); + final resolverB = MediaStoreResolver(store: bucket, cache: cacheB); + + final full = await resolverB.tryResolveRemote(onB, thumbnail: false); + expect(full, isA()); + expect(await (full! as FileData).file.readAsBytes(), videoBytes); + + final thumb = await resolverB.tryResolveRemote(onB, thumbnail: true); + expect(thumb, isA()); + final thumbBytes = await (thumb! as FileData).file.readAsBytes(); + expect(thumbBytes, posterBytes, reason: 'poster round-trips exactly'); + expect(thumbBytes, isNot(equals(videoBytes))); + }); } diff --git a/test/features/media_store/support/fake_local_file_resolver.dart b/test/features/media_store/support/fake_local_file_resolver.dart index 6301142b8..6d48ab58e 100644 --- a/test/features/media_store/support/fake_local_file_resolver.dart +++ b/test/features/media_store/support/fake_local_file_resolver.dart @@ -15,6 +15,10 @@ class FakeLocalFileResolver implements MediaSourceResolver { MediaSourceData data; + /// When set, resolveThumbnail serves this instead of [data] (models the + /// gallery resolver's pre-compressed poster bytes for videos). + MediaSourceData? thumbnailData; + @override MediaSourceType get sourceType => MediaSourceType.localFile; @@ -28,7 +32,7 @@ class FakeLocalFileResolver implements MediaSourceResolver { Future resolveThumbnail( MediaItem item, { required Size target, - }) async => data; + }) async => thumbnailData ?? data; @override Future extractMetadata(MediaItem item) async => null; From f984f951da988c29a46401435f5985da700028e8 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:00:48 -0400 Subject: [PATCH 09/18] docs: media store phase 4 implementation plan --- .../plans/2026-07-10-media-store-phase4.md | 693 ++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-media-store-phase4.md diff --git a/docs/superpowers/plans/2026-07-10-media-store-phase4.md b/docs/superpowers/plans/2026-07-10-media-store-phase4.md new file mode 100644 index 000000000..7f793870d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-media-store-phase4.md @@ -0,0 +1,693 @@ +# Media Store Phase 4 (Backends) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The media store works on all four backends - Dropbox, Google Drive, and iCloud join S3, each with single-shot AND resumable large-file transfers behind the same `MediaObjectStore` contract, selectable from a provider chooser on the Media Storage page - Phase 4 of the Media Store spec (`docs/superpowers/specs/2026-07-10-s3-media-storage-design.md`, sections 8.2, 13, 14, 17). + +**Architecture:** Three new adapters, each using its service's native shape. Dropbox is path-native: keys map to `/submersion-media/` in the app folder, and the existing `DropboxApiClient`'s in-memory session upload is refactored into public file-drivable primitives (`start/append/finish`) plus Range downloads; resume state is `{sessionId, offset}`. Google Drive gets a raw-REST adapter over the sync provider's authenticated `AuthClient`: one `submersion-media` folder in `appDataFolder`, file NAME = full store key, ALL uploads via resumable sessions (one code path), resume via the `Content-Range: bytes */total` probe, downloads via Range. iCloud is a filesystem adapter over the ubiquity container (`/submersion-media/`): small writes via the native `writeFile`, large files via `moveFile` (OS-coordinated), reads via `downloadIfNeeded`, resume OS-managed - behind an injectable `ICloudMediaPlatform` so tests run against a temp directory. Attach state gains a provider type; the runtime builds the right adapter; `MediaStoreService` gains per-provider connect flows; the settings page gains the chooser. + +**Tech Stack:** Existing stack; no new dependencies. New MockClient protocol fakes: `FakeDropboxServer`, `FakeDriveServer` (siblings of Phase 3's `FakeS3Server`). + +## Global Constraints + +- Work ONLY in the worktree: `/Users/ericgriffin/repos/submersion-app/submersion/.claude/worktrees/media-store-phase4` (branch `worktree-media-store-phase4`, stacked on `worktree-media-store-phase3`; the PR targets that branch). +- No schema changes (main v103, local cache v3). Attach state grows one SharedPreferences key. +- TDD; `set -o pipefail` on piped test runs; `--timeout 60s`+ on new suites; Read every file at this worktree's absolute path before its first Edit; format + whole-project analyze (`--fatal-infos` in CI) before every commit; no emojis; conventional single-line commits without trailers; new strings into all 11 arb files. +- Fault-injection fakes must out-stubborn retry policies: `DropboxApiClient` retries 401 once and 429 once; a generic 500 is NOT retried by it (unlike `S3ApiClient`), so one-shot 500 injection works for Dropbox; the Drive adapter's raw REST has NO retry layer (the adapter surfaces errors to the queue's backoff), so one-shot injection works there too. +- The shared behavioral contract (`test/core/services/media_store/media_object_store_contract.dart`) runs against every new adapter's fake-backed instance - that IS the "Phase 1 criteria on all four backends" proof; the per-adapter kill-resume tests are the Phase 3 criteria proof. +- iCloud honesty (spec sections 8.2, 19): resume/progress are OS-managed; the adapter reports a single completion progress tick, and kill-resume is N/A at the adapter level (documented in the PR). iCloud is gated to iOS/macOS via the existing `ICloudNativeService.getAvailability()`. +- Phase 1-3 pieces consumed verbatim: `MediaObjectStore` (with Phase 3's progress/resume parameters), `StoreKeys`, `StoreMarkerStore.ensure/read`, `MediaStoreAttachState`, `MediaStoreService` + providers in `media_store_providers.dart`, `MediaStoresRepository.upsertActive`, contract runner `runMediaObjectStoreContract(name, build)`, `CloudProviderType` enum (`lib/core/data/repositories/sync_repository.dart:14`: icloud/googledrive/s3/dropbox), Dropbox singletons (`DropboxAuthManager`, `DropboxApiClient` callback wiring per `dropbox_storage_provider.dart:31-37`), `GoogleDriveStorageProvider` internals (`_authClient`/`_allowSilentAuth`, google_drive_storage_provider.dart:23-30), `ICloudNativeService` statics, `isApplePlatformProvider` (sync_providers.dart). + +--- + +### Task 1: Dropbox client - public session primitives, Range download, recursive list + +**Files:** +- Modify: `lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart` +- Create: `test/helpers/fake_dropbox_server.dart` +- Test: `test/core/services/cloud_storage/dropbox/dropbox_api_client_sessions_test.dart` + +**Interfaces:** +- Produces (on `DropboxApiClient`): +```dart +Future uploadSessionStart(Uint8List firstChunk); // returns session_id +Future uploadSessionAppend({required String sessionId, required int offset, required Uint8List chunk}); +Future uploadSessionFinish({required String sessionId, required int offset, required String path, required Uint8List lastChunk}); +Future<({Uint8List bytes, int? totalLength})> downloadRange(String path, {required int start, required int endInclusive}); +// listFolder gains: Future> listFolder({String path = '', bool recursive = false}); +``` +`_uploadChunked` is refactored to call the three public primitives (behavior identical; its existing threshold test keeps passing). `downloadRange` sends the `Range` HTTP header on `/2/files/download` and parses `Content-Range` for the total (null when the server answers 200 with the full body). +- Produces (test helper `FakeDropboxServer`): +```dart +class FakeDropboxServer { + final Map files = {}; // path -> bytes ('/x/y.jpg') + final List captured = []; + int sessionAppendCount = 0; // successful appends+finishes with bodies + int? failAfterAppends; // one-shot 500 when reached (no client retry on 500) + MockClient get client; + String bearerToken = 'test-token'; // _handle verifies the Authorization header +} +``` +Handler branches on the request URL path: `/2/files/upload` (store body at `Dropbox-API-Arg`.path), `/2/files/download` (serve bytes; honor `Range` -> 206 + `Content-Range`), `/2/files/get_metadata` (JSON metadata or `{"error_summary": "path/not_found/.."}` with 409), `/2/files/list_folder` (+ `/continue`; respect `recursive` by prefix-matching stored paths; one page, `has_more: false`), `/2/files/delete_v2` (idempotent), `/2/users/get_current_account`, `/2/files/upload_session/start` (mint `session-N`, store chunks), `append_v2` (verify `cursor.offset` equals accumulated length, else 409 `{"error_summary": "incorrect_offset/.."}`), `finish` (concatenate into `files[commit.path]`). Dropbox not-found/errors are HTTP 409 with an `error_summary` JSON body - the client keys off the summary text, not the status. + +- [ ] **Step 1: Write the failing test** + +Create `test/core/services/cloud_storage/dropbox/dropbox_api_client_sessions_test.dart`: + +```dart +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; + +import '../../../../helpers/fake_dropbox_server.dart'; + +void main() { + late FakeDropboxServer server; + late DropboxApiClient client; + + setUp(() { + server = FakeDropboxServer(); + client = DropboxApiClient( + getAccessToken: () async => server.bearerToken, + onAccessTokenRejected: () {}, + httpClient: server.client, + ); + }); + + test('session start, append, finish assembles the file in order', () async { + final sessionId = await client.uploadSessionStart( + Uint8List.fromList(List.filled(8, 1)), + ); + await client.uploadSessionAppend( + sessionId: sessionId, + offset: 8, + chunk: Uint8List.fromList(List.filled(8, 2)), + ); + final meta = await client.uploadSessionFinish( + sessionId: sessionId, + offset: 16, + path: '/m/big.bin', + lastChunk: Uint8List.fromList(List.filled(4, 3)), + ); + expect(meta.pathLower, '/m/big.bin'); + expect(server.files['/m/big.bin'], [ + ...List.filled(8, 1), + ...List.filled(8, 2), + ...List.filled(4, 3), + ]); + }); + + test('append with a wrong offset surfaces incorrect_offset', () async { + final sessionId = await client.uploadSessionStart( + Uint8List.fromList([1, 2]), + ); + await expectLater( + client.uploadSessionAppend( + sessionId: sessionId, + offset: 99, + chunk: Uint8List.fromList([3]), + ), + throwsA( + predicate( + (e) => e.toString().contains('incorrect_offset'), + ), + ), + ); + }); + + test('downloadRange returns the slice and total', () async { + server.files['/m/r.bin'] = Uint8List.fromList( + List.generate(100, (i) => i), + ); + final range = await client.downloadRange( + '/m/r.bin', + start: 10, + endInclusive: 19, + ); + expect(range.bytes, List.generate(10, (i) => i + 10)); + expect(range.totalLength, 100); + }); + + test('recursive listFolder returns nested paths', () async { + server.files['/m/smv1/objects/aa/x.bin'] = Uint8List.fromList([1]); + server.files['/m/smv1/thumbs/aa/x.jpg'] = Uint8List.fromList([2]); + server.files['/other.bin'] = Uint8List.fromList([3]); + final entries = await client.listFolder(path: '/m/smv1', recursive: true); + expect(entries.map((e) => e.pathLower).toSet(), { + '/m/smv1/objects/aa/x.bin', + '/m/smv1/thumbs/aa/x.jpg', + }); + }); +} +``` + +- [ ] **Step 2: Build the fake, run to verify failure, implement** + +Write `test/helpers/fake_dropbox_server.dart` per the Interfaces block (branch on `request.url.path`; content endpoints read `Dropbox-API-Arg` header JSON; RPC endpoints read the JSON body; every branch checks `Authorization == 'Bearer ${bearerToken}'` and returns 401 otherwise). Run the test -> FAIL to compile. Then in `dropbox_api_client.dart`: + +(a) Extract the three public session methods from `_uploadChunked`'s inline calls - each wraps one `_send(_contentRequest(...))` exactly as `_uploadChunked` does today, e.g.: + +```dart + /// Starts an upload session with the first chunk; returns the session id. + Future uploadSessionStart(Uint8List firstChunk) async { + final response = await _send( + () => _contentRequest( + '/2/files/upload_session/start', + arg: {'close': false}, + body: firstChunk, + ), + ); + return _decodeMap(response!)['session_id'] as String; + } +``` +(`uploadSessionAppend` mirrors the append_v2 call; `uploadSessionFinish` mirrors finish and returns `DropboxFileMetadata.fromJson`.) Rewrite `_uploadChunked`'s body to call them (same offsets, same behavior). + +(b) `downloadRange`: `_contentRequest('/2/files/download', arg: {'path': path})` with the Range header added after build: + +```dart + Future<({Uint8List bytes, int? totalLength})> downloadRange( + String path, { + required int start, + required int endInclusive, + }) async { + final response = await _send( + () => _contentRequest('/2/files/download', arg: {'path': path}) + ..headers['Range'] = 'bytes=$start-$endInclusive', + notFoundMessage: 'File not found in Dropbox: $path', + ); + final contentRange = response!.headers['content-range']; + return ( + bytes: response.bodyBytes, + totalLength: contentRange == null + ? null + : int.parse(contentRange.split('/').last), + ); + } +``` +NOTE: `_send` treats 2xx as success; a 206 passes. Verify `_send`'s success check is `>= 200 && < 300` (it is, line 286) before assuming. + +(c) `listFolder` gains `bool recursive = false`, passed into the `list_folder` RPC body. + +- [ ] **Step 3: Run all Dropbox tests** + +```bash +set -o pipefail +flutter test test/core/services/cloud_storage/dropbox/ --timeout 60s 2>&1 | tail -2 +``` +Expected: PASS (existing dropbox client tests plus the new ones - the `_uploadChunked` refactor is covered by whatever session test exists; if none exists, the Task 2 adapter tests cover it end-to-end). + +- [ ] **Step 4: Format, analyze, commit** + +```bash +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): dropbox session primitives and range download" +``` + +--- + +### Task 2: DropboxMediaObjectStore + +**Files:** +- Create: `lib/core/services/media_store/dropbox_media_object_store.dart` +- Test: `test/core/services/media_store/dropbox_media_object_store_test.dart` + +**Interfaces:** +- Produces: +```dart +class DropboxMediaObjectStore implements MediaObjectStore { + DropboxMediaObjectStore({ + required DropboxApiClient client, + this.rootPath = '/submersion-media', + this.chunkSizeBytes = 8 * 1024 * 1024, // session threshold AND chunk size + }); + // key 'smv1/objects/aa/h.jpg' <-> path '/submersion-media/smv1/objects/aa/h.jpg' + // Dropbox path_lower is LOWERCASE - keys are already lowercase (hex hashes, + // fixed literals), so the round trip is safe; assert/document this. +} +``` +- Behavior: `putFile` <= chunk -> single `client.upload`; larger -> session loop over `RandomAccessFile` slices with resume JSON `{"sessionId": "...", "offset": N, "chunkSizeBytes": M}` fired after every acknowledged append, `onProgress` alongside; resume validation is optimistic (append at the recorded offset; an `incorrect_offset` error or any session error aborts to a fresh session - Dropbox has no ListParts equivalent). `getFile`: metadata size via `getMetadata` (null -> notFound `MediaStoreException`); small -> whole `download`; large -> `downloadRange` loop to a `RandomAccessFile`. `head` -> `getMetadata` mapped to `StoreObjectInfo` (key = path minus root prefix). `delete` -> `client.delete` (already idempotent). `list(keyPrefix)` -> `listFolder(path: rootPath + '/' + dirOf(prefix), recursive: true)` filtered by full prefix client-side. Error mapping `_map`: message contains 'not found' -> notFound; 'authorization expired' -> auth; 'Could not reach' or 'rate limit' -> transient; 'out of storage' -> fatal; else fatal. + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/media_store/dropbox_media_object_store_test.dart` - structurally a sibling of `s3_media_object_store_test.dart`: a `build({int? chunkSizeBytes})` helper over `FakeDropboxServer`, plus: + +```dart + // 1. Contract: the shared behavioral suite. + runMediaObjectStoreContract( + 'DropboxMediaObjectStore', + () async { + server = FakeDropboxServer(); + return build(); + }, + ); +``` +(the contract's `setUp` calls `build()` fresh each test; recreate the server inside the builder so state never leaks), and these specific tests: + +- `putFile maps the key under /submersion-media` - put `smv1/objects/ab/abc.jpg`, expect `server.files['/submersion-media/smv1/objects/ab/abc.jpg']`. +- `large putFile goes through a session with progress and resume state` - `build(chunkSizeBytes: 16 * 1024)`, 50 KiB payload -> expect assembled bytes, `resumeJson` contains `"sessionId"`, progress last == length. +- `kill-and-resume appends only the remaining chunks` - `server.failAfterAppends = 1` (start succeeds = chunk 1 stored; the first append dies), expect `MediaStoreException`; capture resume JSON (offset == 16 KiB); clear the injection; fresh `build(...)` + `putFile(resumeStateJson: ...)` -> assembled bytes correct AND `server.sessionAppendCount` shows chunk 1 was not re-sent (count the content-endpoint bodies: start=1, failed append, then appends for chunks 2..N + finish only). +- `a stale resume state (unknown session) restarts fresh` - resume JSON with `"sessionId": "session-nope"` -> file still assembles correctly. +- `chunked getFile round-trips with progress` (large object via `downloadRange` loop). + +Write every test fully with exact byte-array assertions in the style of the S3 adapter test file (that file is the template; only the server fake and path mapping differ). + +- [ ] **Step 2: Run to verify failure, implement the adapter** + +The adapter mirrors `S3MediaObjectStore`'s structure exactly (threshold switch, `_putSession` <-> `_putMultipart`, `_validateResume` simplified to JSON-parse-only, `RandomAccessFile` loops, `_map`); the only structural differences: paths instead of wire keys (`String _path(String key) => '$rootPath/$key';`), resume JSON `{sessionId, offset, chunkSizeBytes}` (mismatch of chunkSizeBytes -> fresh session), Dropbox finish carries the LAST chunk (loop uploads chunks 1..N-1 via append, then finish with chunk N), and getFile sizes come from `getMetadata`. + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/core/services/media_store/dropbox_media_object_store_test.dart --timeout 90s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): dropbox media adapter with session resume" +``` + +--- + +### Task 3: GoogleDriveMediaObjectStore (raw REST, always-resumable) + +**Files:** +- Modify: `lib/core/services/cloud_storage/google_drive_storage_provider.dart` (client accessor, ~line 26) +- Create: `lib/core/services/media_store/google_drive_media_object_store.dart` +- Create: `test/helpers/fake_drive_server.dart` +- Test: `test/core/services/media_store/google_drive_media_object_store_test.dart` + +**Interfaces:** +- `GoogleDriveStorageProvider` gains (after `_initDriveApi`): +```dart + /// Authenticated HTTP client for the media store's raw REST calls. + /// Enables silent auth (the media attach itself is the opt-in) and + /// returns null when no Google session can be established. + Future mediaHttpClient() async { + _allowSilentAuth = true; + if (await isAuthenticated()) return _authClient; + return null; + } +``` +(add `import 'package:http/http.dart' as http;`). +- Produces: +```dart +class GoogleDriveMediaObjectStore implements MediaObjectStore { + GoogleDriveMediaObjectStore({ + required http.Client client, // authenticated + this.folderName = 'submersion-media', + this.chunkSizeBytes = 8 * 1024 * 1024, // MUST be a multiple of 256 KiB (Drive requirement) + String apiBase = 'https://www.googleapis.com', + }); + // One folder in appDataFolder; file NAME = the full store key (Drive + // names allow '/'). All uploads are resumable sessions - one code path. +} +``` +- REST surface used (all relative to `apiBase`): `GET /drive/v3/files?spaces=appDataFolder&q=&fields=files(id,name,modifiedTime,size)` (folder ensure by name+mimeType, key lookup by name+parent, list by parent), `POST /drive/v3/files` (folder create), `POST /upload/drive/v3/files?uploadType=resumable` with JSON `{name, parents}` -> `Location` header session URI (updates of an existing key first DELETE the old file - content-addressed keys never change content, so overwrite only matters for interrupted garbage), `PUT ` with `Content-Range: bytes a-b/total` per chunk -> 308 until the final chunk's 200/201, resume probe `PUT ` with `Content-Range: bytes */total` + empty body -> 308 with `Range: bytes=0-N` (resume at N+1) or 200 (already complete) or 404 (stale -> fresh session), `GET /drive/v3/files/?alt=media` (+ `Range` header for chunked downloads), `DELETE /drive/v3/files/`. +- Resume JSON: `{"sessionUri": "...", "totalBytes": N, "chunkSizeBytes": M}`. `head`/`getFile`/`delete`/`list` resolve key -> fileId by name query each call. +- Error mapping: HTTP 401/403 -> auth; 404 on a resolved id -> notFound (head returns null when the name query is empty); 429/5xx/transport -> transient; else fatal. + +- [ ] **Step 1: Write the failing tests** + +Create `test/helpers/fake_drive_server.dart`: + +```dart +class FakeDriveServer { + final Map filesById = {}; + final Map foldersByName = {}; // name -> id + final List captured = []; + int chunkPutCount = 0; // session PUTs with bodies + int? failAfterChunkPuts; // one-shot 500 when reached + MockClient get client; +} +``` +Handler: `GET /drive/v3/files` parses `q` with two regexes (`name = ''`, `'' in parents`, `mimeType = '...folder'`) and answers `{files: [...]}`; `POST /drive/v3/files` creates a folder id `folder-N`; `POST /upload/drive/v3/files` (uploadType=resumable) reads the JSON `{name, parents}`, mints `session-N` bound to that name, responds 200 with `Location: /fake-session/session-N`; `PUT /fake-session/`: `Content-Range: bytes */total` -> 308 + `Range: bytes=0-` (or 200 with the file JSON if complete, 404 if unknown session); ranged body PUT appends (verify the start offset equals stored length, else 500), 308 until `end+1 == total`, then materialize `filesById['file-N']` and respond 200 `{"id": "file-N", "name": ...}`; `GET /drive/v3/files/?alt=media` serves bytes honoring `Range` (206 + `Content-Range`); `DELETE /drive/v3/files/` -> 204. + +Create `test/core/services/media_store/google_drive_media_object_store_test.dart`: the contract suite (`runMediaObjectStoreContract('GoogleDriveMediaObjectStore', ...)` with a fresh server per build) plus, mirroring the S3/Dropbox adapter test files exactly in structure: +- `putFile stores the key as the file name in the media folder` (assert `filesById.values.single.name == 'smv1/objects/ab/abc.jpg'` and folder ensure ran once). +- `large putFile chunks through one session with progress and resume state` (chunkSizeBytes: 256 KiB test override NOTE - the 256 KiB multiple rule means test chunks are 256 KiB and payloads ~700 KiB). +- `kill-and-resume probes the session and uploads only the tail` (`failAfterChunkPuts = 2`; resume -> probe request observed (`bytes */`), `chunkPutCount` delta covers only the remaining chunks + probe). +- `stale session (404 probe) restarts fresh`. +- `chunked getFile round-trips with progress`. + +- [ ] **Step 2: Run to verify failure, implement** + +Implement the provider accessor and the adapter. The adapter follows the same skeleton as the other two (`_ensureFolderId` cached per instance; `_fileIdForKey(key)` name query; threshold-free: every put opens a session; the chunk loop mirrors S3's `RandomAccessFile` pattern with `Content-Range` headers; `_probeSession(sessionUri, total)` implements the `*/total` resume probe). All requests set `'Content-Type': 'application/json; charset=utf-8'` for metadata calls. Key names in queries must escape single quotes (`key.replaceAll("'", r"\'")`) - keys are hex/fixed so this is belt-and-braces. + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/core/services/media_store/google_drive_media_object_store_test.dart --timeout 90s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): google drive media adapter with resumable sessions" +``` + +--- + +### Task 4: ICloudMediaObjectStore over an injectable platform + +**Files:** +- Create: `lib/core/services/media_store/icloud_media_platform.dart` +- Create: `lib/core/services/media_store/icloud_media_object_store.dart` +- Test: `test/core/services/media_store/icloud_media_object_store_test.dart` + +**Interfaces:** +- Produces: +```dart +/// Thin seam over the iCloud container so the adapter is testable against +/// a temp directory. The default implementation delegates to +/// ICloudNativeService statics + dart:io. +abstract class ICloudMediaPlatform { + Future containerPath(); + Future writeSmallFile(String path, Uint8List data); // native writeFile + Future moveIntoContainer(String sourcePath, String destinationPath); + Future ensureDownloaded(String path); // downloadIfNeeded + Future refreshFolder(String path); // best-effort +} + +class NativeICloudMediaPlatform implements ICloudMediaPlatform { ... } + +/// Temp-directory fake for tests (lives in the SAME file, exported for +/// reuse by later tasks' tests): +class DirectoryICloudMediaPlatform implements ICloudMediaPlatform { + DirectoryICloudMediaPlatform(this.root); + final Directory root; + // containerPath -> root.path; writeSmallFile -> File.writeAsBytes; + // moveIntoContainer -> rename-with-copy-fallback; ensureDownloaded -> + // File.exists; refreshFolder -> no-op. +} + +class ICloudMediaObjectStore implements MediaObjectStore { + ICloudMediaObjectStore({ + required ICloudMediaPlatform platform, + this.rootFolder = 'submersion-media', + this.smallFileThresholdBytes = 8 * 1024 * 1024, + }); + // key -> /submersion-media/ +} +``` +- Behavior: `putFile` ensures parent directories (`Directory(dirname).create(recursive: true)`), then small -> `writeSmallFile(bytes)`, large -> copy source to a sibling temp path inside the container's folder then `moveIntoContainer` (native coordination; the OS uploads in the background - single progress tick, resume params accepted and unused). `getFile`: `ensureDownloaded(path)` -> false or missing file -> notFound; else copy to destination with one progress tick. `head`: `ensureDownloaded` false -> null; else `File.stat` size/modified. `list(keyPrefix)`: `refreshFolder(root)` best-effort, then recursive `Directory.list` filtered to files under the prefix, keys = path minus root. `containerPath() == null` anywhere -> `MediaStoreException(kind: fatal, 'iCloud is not available on this device')`. + +- [ ] **Step 1: Write the failing tests** + +Create `test/core/services/media_store/icloud_media_object_store_test.dart`: the shared contract against `DirectoryICloudMediaPlatform(tempDir)` plus two specifics - `large putFile lands via moveIntoContainer` (use `smallFileThresholdBytes: 1024`, a 4 KiB payload, assert the file exists at the mapped path with correct bytes and the SOURCE staging file is gone (moved)); `null container path maps to a fatal MediaStoreException` (a fake returning null). Full code mirrors the other adapter test files; the contract runner plus ~2 targeted tests suffice because there is no protocol layer here. + +- [ ] **Step 2: Run to verify failure, implement, re-run** + +Both classes are small (~60 lines platform, ~130 adapter). `NativeICloudMediaPlatform` methods are one-liners onto `ICloudNativeService`; `writeSmallFile` wraps `writeFile` and rethrows as `MediaStoreException(fatal)` on failure; the adapter never calls `refreshFolder` on hot paths (only `list`). + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/core/services/media_store/icloud_media_object_store_test.dart --timeout 60s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): icloud media adapter over the ubiquity container" +``` + +--- + +### Task 5: Provider-typed attach state and runtime + +**Files:** +- Modify: `lib/core/services/media_store/media_store_attach_state.dart` +- Modify: `lib/features/media_store/presentation/providers/media_store_providers.dart` (runtime builder) +- Test: modify `test/core/services/media_store/media_store_credentials_test.dart` (attach-state cases) + +**Interfaces:** +- `MediaStoreAttachState` becomes provider-typed: +```dart +static const String providerTypeKey = 'media_store_provider_type'; +Future setAttached(String storeId, {required CloudProviderType providerType}); +Future attachedProviderType(); // null when unset; rows +// persisted before Phase 4 have no provider type: attachedProviderType() +// falls back to CloudProviderType.s3 when a storeId exists (migration-free +// backward compatibility - S3 was the only option). +``` +- `mediaStoreRuntimeProvider` builds the store by type: +```dart + final attachedId = await attachState.attachedStoreId(); + if (attachedId == null) return null; + final providerType = + await attachState.attachedProviderType() ?? CloudProviderType.s3; + final store = await _buildStore(ref, providerType); + if (store == null) return null; // e.g. Drive silent auth unavailable +``` +backed by a shared REF-LESS helper defined in `media_store_service.dart` (Task 6's default factories reuse it verbatim - define it here once): +```dart +/// Builds the store adapter for [type], or null when the provider is not +/// usable right now (missing config, no silent Google session, iCloud +/// unavailable). Used by the runtime provider and the connect flows. +Future buildMediaObjectStore( + CloudProviderType type, { + S3Config? s3Config, +}) async { ... } +``` +s3 -> `s3Config == null ? null : S3MediaObjectStore(client: S3ApiClient(s3Config), keyPrefix: s3Config.prefix)` (the runtime loads the keychain config and passes it in); dropbox -> `DropboxMediaObjectStore(client: DropboxApiClient(getAccessToken: authManager.getAccessToken, onAccessTokenRejected: authManager.invalidateAccessToken))` over a fresh `DropboxAuthManager()`; googledrive -> `cloudProviderInstanceFor(CloudProviderType.googledrive) as GoogleDriveStorageProvider`, then `mediaHttpClient()` (null -> null, else the adapter); icloud -> gated on `ICloudNativeService.getAvailability() == available` (else null), `ICloudMediaObjectStore(platform: NativeICloudMediaPlatform())`. `cloudProviderInstanceFor` is a top-level function in sync_providers.dart; import it. + +- [ ] **Step 1: Write the failing attach-state tests** + +Append to `media_store_credentials_test.dart`: + +```dart + test('attach state records and returns the provider type', () async { + SharedPreferences.setMockInitialValues({}); + final state = MediaStoreAttachState( + prefs: await SharedPreferences.getInstance(), + ); + await state.setAttached('store-1', providerType: CloudProviderType.dropbox); + expect(await state.attachedStoreId(), 'store-1'); + expect(await state.attachedProviderType(), CloudProviderType.dropbox); + await state.clear(); + expect(await state.attachedProviderType(), isNull); + }); + + test('a pre-phase-4 attachment without a provider type reads as S3', + () async { + SharedPreferences.setMockInitialValues({ + MediaStoreAttachState.storeIdKey: 'store-legacy', + }); + final state = MediaStoreAttachState( + prefs: await SharedPreferences.getInstance(), + ); + expect(await state.attachedProviderType(), CloudProviderType.s3); + }); +``` +(import `sync_repository.dart` for the enum.) + +- [ ] **Step 2: Implement attach state, then the runtime switch** + +Attach state: `setAttached` writes both keys; `attachedProviderType()` returns null when NO storeId, `CloudProviderType.s3` when a storeId exists without a stored type, else `CloudProviderType.values.byName(stored)`; `clear()` removes both. Compile-fix the two existing `setAttached('...')` call sites (`MediaStoreService.connectS3` -> `providerType: CloudProviderType.s3`; the service test) - the compiler enumerates them. + +Runtime: extract the current S3 construction into `_buildStore` and add the switch per the Interfaces block. The preflight/gate/worker wiring is store-agnostic and stays untouched. NOTE: `_buildStore` returning null must NOT clear the attachment (a Drive token hiccup is transient); the runtime simply stays null until the next invalidation. + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/core/services/media_store/media_store_credentials_test.dart test/features/media_store/ --timeout 90s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): provider-typed attach state and runtime" +``` + +--- + +### Task 6: Per-provider connect flows in MediaStoreService + +**Files:** +- Modify: `lib/features/media_store/data/media_store_service.dart` +- Test: modify `test/features/media_store/media_store_service_test.dart` + +**Interfaces:** +- Produces: +```dart +class MediaStoreService { + MediaStoreService({ + required MediaStoreCredentialsStore credentials, + required MediaStoreAttachState attachState, + required MediaStoresRepository storesRepository, + MediaObjectStore Function(S3Config config)? storeFactory, + // Phase 4 seams: builders for the managed providers, injectable fakes + // in tests, defaulting to the real adapters. + Future Function()? dropboxStoreFactory, + Future Function()? googleDriveStoreFactory, + Future Function()? icloudStoreFactory, + }); + Future connectS3(S3Config config); // unchanged + providerType on setAttached + Future connectDropbox(); // hint 'Dropbox' + Future connectGoogleDrive(); // hint 'Google Drive' + Future connectICloud(); // hint 'iCloud' + Future disconnect(); // also clears the provider type (attachState.clear does) +} +``` +- Shared private flow `_connectManaged(CloudProviderType type, String displayHint, Future Function() factory)`: factory null -> `MediaStoreException(auth, ' is not connected or unavailable')`; marker ensure via `StoreMarkerStore(store)`; `attachState.setAttached(storeId, providerType: type)`; descriptor upsert with the provider's `providerType.name` and hint; NO credentials-store write (managed providers keep credentials in their own auth stores). `connectS3` keeps the credentials write. +- Default factories delegate to Task 5's `buildMediaObjectStore(type)` (already in this file), e.g. `_dropboxStoreFactory = dropboxStoreFactory ?? (() => buildMediaObjectStore(CloudProviderType.dropbox));`. + +- [ ] **Step 1: Write the failing tests** + +Append to `media_store_service_test.dart`: + +```dart + test('connectDropbox ensures the marker and records provider type', + () async { + final dropboxFake = InMemoryMediaObjectStore(); + final svc = MediaStoreService( + credentials: credentials, + attachState: attachState, + storesRepository: storesRepository, + dropboxStoreFactory: () async => dropboxFake, + ); + final result = await svc.connectDropbox(); + expect(result.createdNewStore, isTrue); + expect(dropboxFake.objects.containsKey('smv1/store.json'), isTrue); + expect(await attachState.attachedProviderType(), CloudProviderType.dropbox); + final active = await storesRepository.getActive(); + expect(active!.providerType, 'dropbox'); + expect(await credentials.load(), isNull, + reason: 'managed providers never touch the S3 keychain entry'); + }); + + test('connect on an unavailable managed provider throws auth', () async { + final svc = MediaStoreService( + credentials: credentials, + attachState: attachState, + storesRepository: storesRepository, + googleDriveStoreFactory: () async => null, + ); + await expectLater( + svc.connectGoogleDrive(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + MediaStoreErrorKind.auth, + ), + ), + ); + expect(await attachState.attachedStoreId(), isNull); + }); +``` +plus a `connectICloud` happy-path clone of the Dropbox test (provider type icloud, hint 'iCloud'). + +- [ ] **Step 2: Implement, run, commit** + +Implement per the Interfaces block (the three public methods are one-liners onto `_connectManaged`). Fix `connectS3`'s `setAttached` call for the new signature. Run: + +```bash +set -o pipefail +flutter test test/features/media_store/media_store_service_test.dart --timeout 60s 2>&1 | tail -2 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): per-provider connect flows" +``` + +--- + +### Task 7: Provider chooser on the Media Storage page + l10n + +**Files:** +- Modify: `lib/features/media_store/presentation/pages/media_storage_page.dart` +- Modify: all 11 `lib/l10n/arb/app_*.arb` (script below) +- Test: modify `test/features/media_store/media_storage_page_test.dart` + +**Interfaces:** +- Page behavior: when DISCONNECTED, a "Provider" `SegmentedButton` (S3 / Dropbox / Google Drive / iCloud; iCloud segment only when `isApplePlatformProvider` is true) selects which connect panel shows below. S3 panel = the existing form (unchanged). Managed panels = a short explainer `Text` + a `FilledButton` (`Key('media-dropbox-connect')` / `'media-gdrive-connect'` / `'media-icloud-connect'`) calling the matching `MediaStoreService.connect*()`, then `ref.invalidate(mediaStoreRuntimeProvider)` + saved-snack + `Navigator.maybePop` (same post-connect sequence as `_connect`). Errors surface via `_showSnack(e.message, isError: true)`. When CONNECTED, the chooser hides (the status card + policies/backfill/transfers/disconnect sections are provider-agnostic and stay). +- New l10n keys (script adds all 11 locales; translations included): + - `settings_mediaStorage_provider_label`: "Provider" / de "Anbieter" / es "Proveedor" / fr "Fournisseur" / it "Provider" / nl "Provider" / pt "Provedor" / hu "Szolgaltato" (restore: Szolgáltató) / zh "服务商" / ar "المزود" / he "ספק" + - `settings_mediaStorage_connect_dropbox_hint`: "Uses your Dropbox connection from Cloud Sync. Media is stored in your Dropbox app folder." (translate per locale in the script, same register as existing strings) + - `settings_mediaStorage_connect_gdrive_hint`: "Signs in with Google. Media is stored in this app's private Drive space." + - `settings_mediaStorage_connect_icloud_hint`: "Media is stored in this app's iCloud container and syncs through your Apple ID." + - `settings_mediaStorage_connect_action`: "Connect {provider}" (String placeholder) + Follow the Phase 2 l10n script pattern (`add_media_storage_phase2_l10n.py` in the session scratchpad is the template): proper diacritics, `@settings_mediaStorage_connect_action` metadata with the placeholder, run + `flutter gen-l10n`, verify the diff is additions-only. +- Provider display names come from the existing enum-adjacent strings if present (check `settings_cloudSync_provider_*` keys in app_en.arb and reuse); otherwise plain literals 'S3', 'Dropbox', 'Google Drive', 'iCloud' are acceptable for segment labels (proper nouns are not translated). + +- [ ] **Step 1: Write the failing widget tests** + +Append to `media_storage_page_test.dart` (the `_RecordingService` gains `int dropboxCalls = 0;` etc. with overrides of the three connect methods returning canned results): + +```dart + testWidgets('provider chooser shows managed connect panel and calls the ' + 'service', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(app()); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + + await tester.tap(find.text('Dropbox')); + await tester.pump(); + expect(find.byKey(const Key('media-dropbox-connect')), findsOneWidget); + expect(find.byKey(const Key('media-s3-endpoint')), findsNothing); + + await tester.runAsync(() async { + await tester.tap(find.byKey(const Key('media-dropbox-connect'))); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + expect(service.dropboxCalls, 1); + }); +``` +plus one asserting the S3 segment is selected by default with the form visible, and one asserting the iCloud segment is absent when `isApplePlatformProvider` is overridden to false (override that provider in the ProviderScope; check its exact name/exported location in sync_providers.dart first). + +- [ ] **Step 2: Run the l10n script, implement the page changes, run tests** + +State additions to `_MediaStoragePageState`: `CloudProviderType _selectedProvider = CloudProviderType.s3;` and a `_connectManaged(Future Function() call)` helper mirroring `_connect`'s busy/snack/invalidate/pop sequence. The build method wraps the existing S3 form widgets in `if (!connected && _selectedProvider == CloudProviderType.s3) ...[...]` and adds the chooser + managed panels. Keep the file under 800 lines - if it crosses, extract the S3 form section into `lib/features/media_store/presentation/widgets/s3_connect_form.dart` as part of this task. + +- [ ] **Step 3: Run, format, analyze, commit** + +```bash +set -o pipefail +flutter test test/features/media_store/media_storage_page_test.dart --timeout 60s 2>&1 | tail -2 +flutter test test/features/settings/ 2>&1 | tail -1 +dart format . && flutter analyze +git add -A +git commit -m "feat(media-store): provider chooser with dropbox, drive, icloud connect" +``` + +--- + +### Task 8: Phase 4 exit verification + stacked PR + +**Files:** +- Test: modify `test/features/media_store/media_store_end_to_end_test.dart` + +- [ ] **Step 1: Protocol-independence e2e** + +Append one test: `the cross-device video flow works over the Dropbox adapter` - clone the Phase 3 video e2e but build device A's pipeline/worker and device B's resolver over `DropboxMediaObjectStore(client: DropboxApiClient(... FakeDropboxServer ...), chunkSizeBytes: 16 * 1024)` with a 64 KiB video (forcing the session path), asserting byte-identical playback and the poster thumb on B. This proves the whole pipeline is adapter-agnostic, not just the adapters contract-compliant. + +- [ ] **Step 2: Full gates** + +```bash +set -o pipefail +flutter test test/features/media_store/ test/core/services/media_store/ test/core/services/cloud_storage/ --timeout 90s 2>&1 | tail -1 +flutter test test/features/media/ 2>&1 | tail -1 +flutter test test/core/services/sync/ 2>&1 | tail -1 +flutter test test/features/settings/ 2>&1 | tail -1 +dart format . && flutter analyze +``` +Expected: all PASS, no issues. + +- [ ] **Step 3: Commit, push, stacked PR** + +```bash +git add -A && git commit -m "feat(media-store): phase 4 exit coverage" +git push -u origin worktree-media-store-phase4 --no-verify +env -u GITHUB_TOKEN gh pr create \ + --title "feat(media-store): backends, phase 4 (dropbox, google drive, icloud)" \ + --base worktree-media-store-phase3 --head worktree-media-store-phase4 \ + --body-file /pr_body_phase4.md +``` +PR body: repo template; STACKED on #557 (-> #556 -> #550, merge bottom-up); notes iCloud's OS-managed resume and the real-account/manual items (Dropbox + Drive live smoke, iCloud two-device smoke on real hardware per the icloud-needs-devices constraint). + +## Phase 4 exit criteria (spec section 17) + +- [ ] The shared MediaObjectStore contract passes on all four adapters (fake-backed) +- [ ] Kill-and-resume proven for Dropbox sessions and Drive resumable sessions (request counting); iCloud documented as OS-managed +- [ ] Chunked, progress-reporting downloads on Dropbox and Drive; iCloud via ensureDownloaded +- [ ] Provider chooser connects each backend; attach state and runtime are provider-typed; legacy S3 attachments keep working without migration +- [ ] Cross-device video e2e passes over a non-S3 adapter + From 0c36b3c4ee7b77ab0220b00e8e0c8e768a49f20d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:36:22 -0400 Subject: [PATCH 10/18] feat(media-store): dropbox session primitives and range download --- .../dropbox/dropbox_api_client.dart | 125 +++++++++---- .../dropbox_api_client_sessions_test.dart | 79 +++++++++ test/helpers/fake_dropbox_server.dart | 165 ++++++++++++++++++ 3 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 test/core/services/cloud_storage/dropbox/dropbox_api_client_sessions_test.dart create mode 100644 test/helpers/fake_dropbox_server.dart diff --git a/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart b/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart index 3657e1e46..990898ffa 100644 --- a/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart +++ b/lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart @@ -113,14 +113,18 @@ class DropboxApiClient { return DropboxFileMetadata.fromJson(_decodeMap(response)); } - /// All files directly in [path] ('' is the app-folder root), following - /// pagination cursors to exhaustion. Folders are omitted. - Future> listFolder({String path = ''}) async { + /// All files in [path] ('' is the app-folder root), following + /// pagination cursors to exhaustion. Folders are omitted. [recursive] + /// includes nested files (media store listing). + Future> listFolder({ + String path = '', + bool recursive = false, + }) async { final entries = []; var response = await _send( () => _rpcRequest('/2/files/list_folder', { 'path': path, - 'recursive': false, + 'recursive': recursive, }), ); while (true) { @@ -162,6 +166,79 @@ class DropboxApiClient { ); } + /// Byte-range read of [path]: [start]..[endInclusive]. [totalLength] is + /// parsed from Content-Range (null when the server answered with the + /// whole body). + Future<({Uint8List bytes, int? totalLength})> downloadRange( + String path, { + required int start, + required int endInclusive, + }) async { + final response = await _send( + () => + _contentRequest('/2/files/download', arg: {'path': path}) + ..headers['Range'] = 'bytes=$start-$endInclusive', + notFoundMessage: 'File not found in Dropbox: $path', + ); + final contentRange = response!.headers['content-range']; + return ( + bytes: response.bodyBytes, + totalLength: contentRange == null + ? null + : int.parse(contentRange.split('/').last), + ); + } + + /// Starts an upload session with the first chunk; returns the session id. + Future uploadSessionStart(Uint8List firstChunk) async { + final response = await _send( + () => _contentRequest( + '/2/files/upload_session/start', + arg: {'close': false}, + body: firstChunk, + ), + ); + return _decodeMap(response!)['session_id'] as String; + } + + /// Appends [chunk] at [offset] (the total bytes already in the session). + Future uploadSessionAppend({ + required String sessionId, + required int offset, + required Uint8List chunk, + }) async { + await _send( + () => _contentRequest( + '/2/files/upload_session/append_v2', + arg: { + 'cursor': {'session_id': sessionId, 'offset': offset}, + 'close': false, + }, + body: chunk, + ), + ); + } + + /// Commits the session to [path] with the final [lastChunk]. + Future uploadSessionFinish({ + required String sessionId, + required int offset, + required String path, + required Uint8List lastChunk, + }) async { + final response = await _send( + () => _contentRequest( + '/2/files/upload_session/finish', + arg: { + 'cursor': {'session_id': sessionId, 'offset': offset}, + 'commit': {'path': path, 'mode': 'overwrite', 'mute': true}, + }, + body: lastChunk, + ), + ); + return DropboxFileMetadata.fromJson(_decodeMap(response!)); + } + void close() => _http.close(); Future _uploadChunked( @@ -169,14 +246,7 @@ class DropboxApiClient { Uint8List data, ) async { final first = Uint8List.sublistView(data, 0, uploadChunkBytes); - final startResponse = await _send( - () => _contentRequest( - '/2/files/upload_session/start', - arg: {'close': false}, - body: first, - ), - ); - final sessionId = _decodeMap(startResponse!)['session_id'] as String; + final sessionId = await uploadSessionStart(first); var offset = first.length; // Append full chunks, leaving at least one byte for finish (Dropbox @@ -188,33 +258,20 @@ class DropboxApiClient { offset, offset + uploadChunkBytes, ); - final sendOffset = offset; - await _send( - () => _contentRequest( - '/2/files/upload_session/append_v2', - arg: { - 'cursor': {'session_id': sessionId, 'offset': sendOffset}, - 'close': false, - }, - body: chunk, - ), + await uploadSessionAppend( + sessionId: sessionId, + offset: offset, + chunk: chunk, ); offset += chunk.length; } - final rest = Uint8List.sublistView(data, offset); - final finishOffset = offset; - final finishResponse = await _send( - () => _contentRequest( - '/2/files/upload_session/finish', - arg: { - 'cursor': {'session_id': sessionId, 'offset': finishOffset}, - 'commit': {'path': path, 'mode': 'overwrite', 'mute': true}, - }, - body: rest, - ), + return uploadSessionFinish( + sessionId: sessionId, + offset: offset, + path: path, + lastChunk: Uint8List.sublistView(data, offset), ); - return DropboxFileMetadata.fromJson(_decodeMap(finishResponse!)); } http.Request _rpcRequest(String path, Map? body) { diff --git a/test/core/services/cloud_storage/dropbox/dropbox_api_client_sessions_test.dart b/test/core/services/cloud_storage/dropbox/dropbox_api_client_sessions_test.dart new file mode 100644 index 000000000..0613d0666 --- /dev/null +++ b/test/core/services/cloud_storage/dropbox/dropbox_api_client_sessions_test.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; + +import '../../../../helpers/fake_dropbox_server.dart'; + +void main() { + late FakeDropboxServer server; + late DropboxApiClient client; + + setUp(() { + server = FakeDropboxServer(); + client = DropboxApiClient( + getAccessToken: () async => server.bearerToken, + onAccessTokenRejected: () {}, + httpClient: server.client, + ); + }); + + test('session start, append, finish assembles the file in order', () async { + final sessionId = await client.uploadSessionStart( + Uint8List.fromList(List.filled(8, 1)), + ); + await client.uploadSessionAppend( + sessionId: sessionId, + offset: 8, + chunk: Uint8List.fromList(List.filled(8, 2)), + ); + final meta = await client.uploadSessionFinish( + sessionId: sessionId, + offset: 16, + path: '/m/big.bin', + lastChunk: Uint8List.fromList(List.filled(4, 3)), + ); + expect(meta.pathLower, '/m/big.bin'); + expect(server.files['/m/big.bin'], [ + ...List.filled(8, 1), + ...List.filled(8, 2), + ...List.filled(4, 3), + ]); + }); + + test('append with a wrong offset surfaces incorrect_offset', () async { + final sessionId = await client.uploadSessionStart( + Uint8List.fromList([1, 2]), + ); + await expectLater( + client.uploadSessionAppend( + sessionId: sessionId, + offset: 99, + chunk: Uint8List.fromList([3]), + ), + throwsA(predicate((e) => e.toString().contains('incorrect_offset'))), + ); + }); + + test('downloadRange returns the slice and total', () async { + server.files['/m/r.bin'] = Uint8List.fromList(List.generate(100, (i) => i)); + final range = await client.downloadRange( + '/m/r.bin', + start: 10, + endInclusive: 19, + ); + expect(range.bytes, List.generate(10, (i) => i + 10)); + expect(range.totalLength, 100); + }); + + test('recursive listFolder returns nested paths', () async { + server.files['/m/smv1/objects/aa/x.bin'] = Uint8List.fromList([1]); + server.files['/m/smv1/thumbs/aa/x.jpg'] = Uint8List.fromList([2]); + server.files['/other.bin'] = Uint8List.fromList([3]); + final entries = await client.listFolder(path: '/m/smv1', recursive: true); + expect(entries.map((e) => e.pathLower).toSet(), { + '/m/smv1/objects/aa/x.bin', + '/m/smv1/thumbs/aa/x.jpg', + }); + }); +} diff --git a/test/helpers/fake_dropbox_server.dart b/test/helpers/fake_dropbox_server.dart new file mode 100644 index 000000000..9888c6267 --- /dev/null +++ b/test/helpers/fake_dropbox_server.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// In-memory Dropbox API v2 fake for MockClient: content + RPC endpoints, +/// upload sessions, Range downloads. Dropbox errors are HTTP 409 with an +/// error_summary JSON body; the client keys off the summary text. +class FakeDropboxServer { + /// Absolute app-folder paths ('/x/y.jpg') -> bytes. + final Map files = {}; + + final List captured = []; + + final Map _sessions = {}; + int _sessionCounter = 0; + + /// Successful append_v2 writes. + int sessionAppendCount = 0; + + /// One-shot 500 on the append_v2 that would take the count to this + /// value + 1 (DropboxApiClient does not retry 5xx, so one shot works). + int? failAfterAppends; + + String bearerToken = 'test-token'; + + MockClient get client => MockClient(_handle); + + Future _handle(http.Request request) async { + captured.add(request); + if (request.headers['Authorization'] != 'Bearer $bearerToken') { + return http.Response('{"error_summary": "invalid_access_token/.."}', 401); + } + final path = request.url.path; + switch (path) { + case '/2/files/upload': + final arg = _arg(request); + final target = arg['path'] as String; + files[target] = Uint8List.fromList(request.bodyBytes); + return http.Response(jsonEncode(_metadata(target)), 200); + case '/2/files/download': + final target = _arg(request)['path'] as String; + final body = files[target]; + if (body == null) return _notFound(); + final range = request.headers['Range'] ?? request.headers['range']; + if (range != null) { + final match = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(range)!; + final start = int.parse(match.group(1)!); + final end = int.parse(match.group(2)!).clamp(0, body.length - 1); + return http.Response.bytes( + body.sublist(start, end + 1), + 206, + headers: {'content-range': 'bytes $start-$end/${body.length}'}, + ); + } + return http.Response.bytes(body, 200); + case '/2/files/get_metadata': + final target = _body(request)['path'] as String; + if (!files.containsKey(target)) return _notFound(); + return http.Response(jsonEncode(_metadata(target)), 200); + case '/2/files/list_folder': + final body = _body(request); + final folder = body['path'] as String; + final recursive = body['recursive'] == true; + final prefix = folder.isEmpty ? '/' : '$folder/'; + final entries = files.keys.where((k) => k.startsWith(prefix)).where(( + k, + ) { + if (recursive) return true; + return !k.substring(prefix.length).contains('/'); + }); + return http.Response( + jsonEncode({ + 'entries': [for (final k in entries) _metadata(k)], + 'has_more': false, + }), + 200, + ); + case '/2/files/delete_v2': + files.remove(_body(request)['path'] as String); + return http.Response('{}', 200); + case '/2/users/get_current_account': + return http.Response( + jsonEncode({ + 'email': 'diver@example.com', + 'name': {'display_name': 'Test Diver'}, + }), + 200, + ); + case '/2/files/upload_session/start': + _sessionCounter++; + final id = 'session-$_sessionCounter'; + _sessions[id] = BytesBuilder()..add(request.bodyBytes); + return http.Response(jsonEncode({'session_id': id}), 200); + case '/2/files/upload_session/append_v2': + final arg = _arg(request); + final cursor = arg['cursor'] as Map; + final session = _sessions[cursor['session_id']]; + if (session == null) { + return http.Response( + '{"error_summary": "lookup_failed/not_found/.."}', + 409, + ); + } + if (cursor['offset'] != session.length) { + return http.Response( + '{"error_summary": "lookup_failed/incorrect_offset/.."}', + 409, + ); + } + final limit = failAfterAppends; + if (limit != null && sessionAppendCount >= limit) { + failAfterAppends = null; + return http.Response('', 500); + } + sessionAppendCount++; + session.add(request.bodyBytes); + return http.Response('{}', 200); + case '/2/files/upload_session/finish': + final arg = _arg(request); + final cursor = arg['cursor'] as Map; + final commit = arg['commit'] as Map; + final session = _sessions.remove(cursor['session_id']); + if (session == null) { + return http.Response( + '{"error_summary": "lookup_failed/not_found/.."}', + 409, + ); + } + if (cursor['offset'] != session.length) { + return http.Response( + '{"error_summary": "lookup_failed/incorrect_offset/.."}', + 409, + ); + } + session.add(request.bodyBytes); + final target = commit['path'] as String; + files[target] = session.toBytes(); + return http.Response(jsonEncode(_metadata(target)), 200); + default: + return http.Response( + '{"error_summary": "unknown_endpoint/$path"}', + 400, + ); + } + } + + Map _arg(http.Request request) => + jsonDecode(request.headers['Dropbox-API-Arg']!) as Map; + + Map _body(http.Request request) => + jsonDecode(request.body) as Map; + + Map _metadata(String path) => { + '.tag': 'file', + 'path_lower': path, + 'name': path.split('/').last, + 'server_modified': '2026-07-09T00:00:00Z', + 'size': files[path]?.length ?? 0, + }; + + http.Response _notFound() => + http.Response('{"error_summary": "path/not_found/.."}', 409); +} From 26d1112c7b3f57e0ae325b26b13d29dbaae8e088 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:39:20 -0400 Subject: [PATCH 11/18] feat(media-store): dropbox media adapter with session resume --- .../dropbox_media_object_store.dart | 290 ++++++++++++++++++ .../dropbox_media_object_store_test.dart | 142 +++++++++ 2 files changed, 432 insertions(+) create mode 100644 lib/core/services/media_store/dropbox_media_object_store.dart create mode 100644 test/core/services/media_store/dropbox_media_object_store_test.dart diff --git a/lib/core/services/media_store/dropbox_media_object_store.dart b/lib/core/services/media_store/dropbox_media_object_store.dart new file mode 100644 index 000000000..ef031657a --- /dev/null +++ b/lib/core/services/media_store/dropbox_media_object_store.dart @@ -0,0 +1,290 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +/// Dropbox-backed media object store (design spec section 8.2). +/// +/// Dropbox is path-native: store keys map to +/// `'$rootPath/[key]'` inside the app folder. Keys are lowercase by +/// construction (hex hashes and fixed literals), so Dropbox's lower-cased +/// path_lower round-trips them safely. Objects above [chunkSizeBytes] go +/// through upload sessions with resume state {sessionId, offset}; resume +/// validation is optimistic - a session error on resume restarts fresh +/// (Dropbox has no ListParts equivalent). +class DropboxMediaObjectStore implements MediaObjectStore { + DropboxMediaObjectStore({ + required DropboxApiClient client, + this.rootPath = '/submersion-media', + this.chunkSizeBytes = 8 * 1024 * 1024, + }) : _client = client; + + final DropboxApiClient _client; + final String rootPath; + + /// Session chunk size, and the single-shot/session threshold. + final int chunkSizeBytes; + + String _path(String key) => '$rootPath/$key'; + + String _key(String pathLower) => pathLower.startsWith('$rootPath/') + ? pathLower.substring(rootPath.length + 1) + : pathLower; + + @override + Future head(String key) async { + try { + final meta = await _client.getMetadata(_path(key)); + if (meta == null) return null; + return StoreObjectInfo( + key: key, + sizeBytes: meta.size, + lastModified: meta.serverModified, + ); + } on CloudStorageException catch (e) { + throw _map('head', key, e); + } + } + + @override + Future putFile( + String key, + File source, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }) async { + final int length; + try { + length = await source.length(); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + if (length <= chunkSizeBytes) { + try { + await _client.upload(_path(key), await source.readAsBytes()); + onProgress?.call(length, length); + } on CloudStorageException catch (e) { + throw _map('put', key, e); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + return; + } + await _putSession( + key, + source, + length, + onProgress: onProgress, + resumeStateJson: resumeStateJson, + onResumeStateChanged: onResumeStateChanged, + ); + } + + Future _putSession( + String key, + File source, + int length, { + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }) async { + final resume = _parseResume(resumeStateJson); + final resuming = resume != null; + try { + String sessionId; + int offset; + final raf = await source.open(); + try { + if (resume != null) { + sessionId = resume.sessionId; + offset = resume.offset; + } else { + final first = await raf.read(min(chunkSizeBytes, length)); + sessionId = await _client.uploadSessionStart(first); + offset = first.length; + onResumeStateChanged?.call(_resumeToJson(sessionId, offset)); + onProgress?.call(offset, length); + } + + while (offset < length) { + await raf.setPosition(offset); + final remaining = length - offset; + final isLast = remaining <= chunkSizeBytes; + final chunk = await raf.read(min(chunkSizeBytes, remaining)); + if (isLast) { + await _client.uploadSessionFinish( + sessionId: sessionId, + offset: offset, + path: _path(key), + lastChunk: chunk, + ); + offset += chunk.length; + onProgress?.call(offset, length); + } else { + await _client.uploadSessionAppend( + sessionId: sessionId, + offset: offset, + chunk: chunk, + ); + offset += chunk.length; + onResumeStateChanged?.call(_resumeToJson(sessionId, offset)); + onProgress?.call(offset, length); + } + } + } finally { + await raf.close(); + } + } on CloudStorageException catch (e) { + final text = e.toString(); + final staleSession = + resuming && + (text.contains('incorrect_offset') || text.contains('not_found')); + if (staleSession) { + // The recorded session is unusable; start over from scratch. + return _putSession( + key, + source, + length, + onProgress: onProgress, + onResumeStateChanged: onResumeStateChanged, + ); + } + throw _map('put', key, e); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + } + + ({String sessionId, int offset})? _parseResume(String? json) { + if (json == null) return null; + try { + final decoded = jsonDecode(json); + if (decoded is! Map) return null; + final sessionId = decoded['sessionId'] as String?; + final offset = (decoded['offset'] as num?)?.toInt(); + final recordedChunk = (decoded['chunkSizeBytes'] as num?)?.toInt(); + if (sessionId == null || offset == null) return null; + if (recordedChunk != chunkSizeBytes) return null; + return (sessionId: sessionId, offset: offset); + } on Exception { + return null; + } + } + + String _resumeToJson(String sessionId, int offset) => jsonEncode({ + 'sessionId': sessionId, + 'offset': offset, + 'chunkSizeBytes': chunkSizeBytes, + }); + + @override + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }) async { + try { + final meta = await _client.getMetadata(_path(key)); + if (meta == null) { + throw MediaStoreException( + 'not found: $key', + kind: MediaStoreErrorKind.notFound, + ); + } + final total = meta.size; + if (total == null || total <= chunkSizeBytes) { + final bytes = await _client.download(_path(key)); + await destination.writeAsBytes(bytes, flush: true); + onProgress?.call(bytes.length, bytes.length); + return; + } + final raf = await destination.open(mode: FileMode.write); + try { + var received = 0; + while (received < total) { + final end = min(received + chunkSizeBytes, total) - 1; + final range = await _client.downloadRange( + _path(key), + start: received, + endInclusive: end, + ); + await raf.writeFrom(range.bytes); + received += range.bytes.length; + onProgress?.call(received, total); + } + await raf.flush(); + } finally { + await raf.close(); + } + } on CloudStorageException catch (e) { + throw _map('get', key, e); + } + } + + @override + Future delete(String key) async { + try { + await _client.delete(_path(key)); + } on CloudStorageException catch (e) { + throw _map('delete', key, e); + } + } + + @override + Stream list(String keyPrefix) async* { + final List entries; + try { + entries = await _client.listFolder(path: rootPath, recursive: true); + } on CloudStorageException catch (e) { + throw _map('list', keyPrefix, e); + } + for (final meta in entries) { + final key = _key(meta.pathLower); + if (!key.startsWith(keyPrefix)) continue; + yield StoreObjectInfo( + key: key, + sizeBytes: meta.size, + lastModified: meta.serverModified, + ); + } + } + + /// Classifies DropboxApiClient's user-facing messages into the retry + /// taxonomy (see the client's _send error policy). + MediaStoreException _map(String op, String key, CloudStorageException e) { + final message = e.message; + final MediaStoreErrorKind kind; + if (message.contains('not found')) { + kind = MediaStoreErrorKind.notFound; + } else if (message.contains('authorization expired')) { + kind = MediaStoreErrorKind.auth; + } else if (message.contains('Could not reach') || + message.contains('rate limit')) { + kind = MediaStoreErrorKind.transient; + } else { + kind = MediaStoreErrorKind.fatal; + } + return MediaStoreException( + '$op $key failed: $message', + kind: kind, + cause: e, + ); + } +} diff --git a/test/core/services/media_store/dropbox_media_object_store_test.dart b/test/core/services/media_store/dropbox_media_object_store_test.dart new file mode 100644 index 000000000..67f52a52a --- /dev/null +++ b/test/core/services/media_store/dropbox_media_object_store_test.dart @@ -0,0 +1,142 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; +import 'package:submersion/core/services/media_store/dropbox_media_object_store.dart'; +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +import '../../../helpers/fake_dropbox_server.dart'; +import 'media_object_store_contract.dart'; + +void main() { + late Directory tmp; + late FakeDropboxServer server; + + DropboxMediaObjectStore build({int? chunkSizeBytes}) { + return DropboxMediaObjectStore( + client: DropboxApiClient( + getAccessToken: () async => server.bearerToken, + onAccessTokenRejected: () {}, + httpClient: server.client, + ), + chunkSizeBytes: chunkSizeBytes ?? 8 * 1024 * 1024, + ); + } + + setUp(() async { + server = FakeDropboxServer(); + tmp = await Directory.systemTemp.createTemp('dropbox_mos_test'); + }); + + tearDown(() => tmp.delete(recursive: true)); + + runMediaObjectStoreContract('DropboxMediaObjectStore', () async { + server = FakeDropboxServer(); + return build(); + }); + + test('putFile maps the key under /submersion-media', () async { + final store = build(); + final src = File('${tmp.path}/a.jpg')..writeAsBytesSync([1, 2, 3]); + await store.putFile( + 'smv1/objects/ab/abc.jpg', + src, + contentType: 'image/jpeg', + ); + expect(server.files['/submersion-media/smv1/objects/ab/abc.jpg'], [ + 1, + 2, + 3, + ]); + }); + + test('large putFile goes through a session with progress and resume ' + 'state', () async { + final store = build(chunkSizeBytes: 16 * 1024); + final bytes = List.generate(50 * 1024, (i) => i % 251); + final src = File('${tmp.path}/video.mp4')..writeAsBytesSync(bytes); + + final progress = []; + String? resumeJson; + await store.putFile( + 'smv1/objects/aa/video.mp4', + src, + contentType: 'video/mp4', + onProgress: (sent, total) => progress.add(sent), + onResumeStateChanged: (json) => resumeJson = json, + ); + + expect(server.files['/submersion-media/smv1/objects/aa/video.mp4'], bytes); + expect(progress.last, bytes.length); + expect(resumeJson, contains('"sessionId"')); + }); + + test('kill-and-resume appends only the remaining chunks', () async { + final store = build(chunkSizeBytes: 16 * 1024); + final bytes = List.generate(64 * 1024, (i) => (i * 3) % 251); + final src = File('${tmp.path}/kr.mp4')..writeAsBytesSync(bytes); + + String? resumeJson; + // start stores chunk 1; the FIRST append (chunk 2) dies once. + server.failAfterAppends = 0; + await expectLater( + store.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + onResumeStateChanged: (json) => resumeJson = json, + ), + throwsA(isA()), + ); + expect(resumeJson, contains('"offset":16384')); + expect(server.sessionAppendCount, 0); + + final appendsBefore = server.sessionAppendCount; + final resumed = build(chunkSizeBytes: 16 * 1024); + await resumed.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: resumeJson, + ); + expect(server.files['/submersion-media/smv1/objects/aa/kr.mp4'], bytes); + // 64 KiB / 16 KiB = 4 chunks: chunk 1 in start, chunks 2-3 as appends, + // chunk 4 in finish. Resume re-sends only chunks 2-3 as appends. + expect(server.sessionAppendCount - appendsBefore, 2); + }); + + test('a stale resume state (unknown session) restarts fresh', () async { + final store = build(chunkSizeBytes: 16 * 1024); + final bytes = List.generate(40 * 1024, (i) => i % 199); + final src = File('${tmp.path}/stale.mp4')..writeAsBytesSync(bytes); + + await store.putFile( + 'smv1/objects/aa/stale.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: + '{"sessionId":"session-nope","offset":16384,' + '"chunkSizeBytes":16384}', + ); + expect(server.files['/submersion-media/smv1/objects/aa/stale.mp4'], bytes); + }); + + test('chunked getFile round-trips with progress', () async { + final store = build(chunkSizeBytes: 16 * 1024); + final bytes = List.generate(50 * 1024, (i) => (i * 7) % 251); + server.files['/submersion-media/smv1/objects/aa/big.bin'] = + Uint8List.fromList(bytes); + + final dest = File('${tmp.path}/big.out'); + final progress = []; + await store.getFile( + 'smv1/objects/aa/big.bin', + dest, + onProgress: (received, total) => progress.add(received), + ); + expect(await dest.readAsBytes(), bytes); + expect(progress.length, greaterThan(1)); + expect(progress.last, bytes.length); + }); +} From 3d1a83fd4d92d6f8830df35182739350ebdb37c9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:42:13 -0400 Subject: [PATCH 12/18] feat(media-store): google drive media adapter with resumable sessions --- .../google_drive_storage_provider.dart | 10 + .../google_drive_media_object_store.dart | 371 ++++++++++++++++++ .../google_drive_media_object_store_test.dart | 171 ++++++++ test/helpers/fake_drive_server.dart | 167 ++++++++ 4 files changed, 719 insertions(+) create mode 100644 lib/core/services/media_store/google_drive_media_object_store.dart create mode 100644 test/core/services/media_store/google_drive_media_object_store_test.dart create mode 100644 test/helpers/fake_drive_server.dart diff --git a/lib/core/services/cloud_storage/google_drive_storage_provider.dart b/lib/core/services/cloud_storage/google_drive_storage_provider.dart index 9010f7430..42f34fb0c 100644 --- a/lib/core/services/cloud_storage/google_drive_storage_provider.dart +++ b/lib/core/services/cloud_storage/google_drive_storage_provider.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; import 'package:google_sign_in/google_sign_in.dart'; +import 'package:http/http.dart' as http; import 'package:googleapis/drive/v3.dart' as drive; import 'package:googleapis_auth/googleapis_auth.dart' as gapis_auth; @@ -150,6 +151,15 @@ class GoogleDriveStorageProvider return api; } + /// Authenticated HTTP client for the media store's raw REST calls. + /// Enables silent auth (the media attach itself is the opt-in) and + /// returns null when no Google session can be established. + Future mediaHttpClient() async { + _allowSilentAuth = true; + if (await isAuthenticated()) return _authClient; + return null; + } + @override Future uploadFile( Uint8List data, diff --git a/lib/core/services/media_store/google_drive_media_object_store.dart b/lib/core/services/media_store/google_drive_media_object_store.dart new file mode 100644 index 000000000..5684386aa --- /dev/null +++ b/lib/core/services/media_store/google_drive_media_object_store.dart @@ -0,0 +1,371 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +/// Google Drive-backed media object store (design spec section 8.2), raw +/// REST over an authenticated client. One folder in appDataFolder holds +/// every object; the file NAME is the full store key (Drive names allow +/// '/'). ALL uploads go through resumable sessions - one code path for +/// photos and videos - with resume via the `Content-Range: bytes */total` +/// probe. [chunkSizeBytes] must be a multiple of 256 KiB (Drive rule). +class GoogleDriveMediaObjectStore implements MediaObjectStore { + GoogleDriveMediaObjectStore({ + required http.Client client, + this.folderName = 'submersion-media', + this.chunkSizeBytes = 8 * 1024 * 1024, + String apiBase = 'https://www.googleapis.com', + }) : _client = client, + _apiBase = apiBase; + + final http.Client _client; + final String folderName; + final int chunkSizeBytes; + final String _apiBase; + + String? _folderId; + + @override + Future head(String key) async { + final found = await _findByKey(key); + if (found == null) return null; + return StoreObjectInfo( + key: key, + sizeBytes: found.size, + lastModified: found.modified ?? DateTime.now(), + ); + } + + @override + Future putFile( + String key, + File source, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }) async { + final int length; + try { + length = await source.length(); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + + var sessionUri = _parseResume(resumeStateJson, length); + var offset = 0; + if (sessionUri != null) { + final probed = await _probeSession(sessionUri, length); + if (probed == null) { + sessionUri = null; // stale session: start over + } else if (probed == length) { + onProgress?.call(length, length); + return; // the previous attempt actually completed + } else { + offset = probed; + } + } + sessionUri ??= await _startSession(key, contentType); + if (offset == 0) { + onResumeStateChanged?.call(_resumeToJson(sessionUri, length)); + } + + final raf = await source.open(); + try { + while (offset < length) { + await raf.setPosition(offset); + final chunk = await raf.read(min(chunkSizeBytes, length - offset)); + final end = offset + chunk.length - 1; + final response = await _send( + http.Request('PUT', Uri.parse(sessionUri)) + ..headers['Content-Range'] = 'bytes $offset-$end/$length' + ..bodyBytes = chunk, + ); + if (response.statusCode != 308 && + response.statusCode != 200 && + response.statusCode != 201) { + throw _forStatus('put', key, response); + } + offset += chunk.length; + onResumeStateChanged?.call(_resumeToJson(sessionUri, length)); + onProgress?.call(offset, length); + } + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } finally { + await raf.close(); + } + } + + /// The server-confirmed byte count for a session, null when the session + /// is stale, or the total when the upload already completed. + Future _probeSession(String sessionUri, int total) async { + final response = await _send( + http.Request('PUT', Uri.parse(sessionUri)) + ..headers['Content-Range'] = 'bytes */$total', + ); + if (response.statusCode == 200 || response.statusCode == 201) return total; + if (response.statusCode != 308) return null; + final range = response.headers['range']; + if (range == null) return 0; + final match = RegExp(r'bytes=0-(\d+)').firstMatch(range); + return match == null ? 0 : int.parse(match.group(1)!) + 1; + } + + Future _startSession(String key, String contentType) async { + final folderId = await _ensureFolder(); + // Content-addressed keys never change content; a leftover file for the + // same key can only be interrupted garbage - replace it. + final existing = await _findByKey(key); + if (existing != null) await _deleteById(existing.id); + + final response = await _send( + http.Request( + 'POST', + Uri.parse('$_apiBase/upload/drive/v3/files?uploadType=resumable'), + ) + ..headers['Content-Type'] = 'application/json; charset=utf-8' + ..headers['X-Upload-Content-Type'] = contentType + ..body = jsonEncode({ + 'name': key, + 'parents': [folderId], + }), + ); + if (response.statusCode != 200) { + throw _forStatus('start session for', key, response); + } + final location = response.headers['location']; + if (location == null || location.isEmpty) { + throw MediaStoreException( + 'Drive returned no session URI for $key', + kind: MediaStoreErrorKind.fatal, + ); + } + return location; + } + + String? _parseResume(String? json, int length) { + if (json == null) return null; + try { + final decoded = jsonDecode(json); + if (decoded is! Map) return null; + if ((decoded['totalBytes'] as num?)?.toInt() != length) return null; + if ((decoded['chunkSizeBytes'] as num?)?.toInt() != chunkSizeBytes) { + return null; + } + return decoded['sessionUri'] as String?; + } on Exception { + return null; + } + } + + String _resumeToJson(String sessionUri, int total) => jsonEncode({ + 'sessionUri': sessionUri, + 'totalBytes': total, + 'chunkSizeBytes': chunkSizeBytes, + }); + + @override + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }) async { + final found = await _findByKey(key); + if (found == null) { + throw MediaStoreException( + 'not found: $key', + kind: MediaStoreErrorKind.notFound, + ); + } + final total = found.size; + if (total == null || total <= chunkSizeBytes) { + final response = await _send( + http.Request( + 'GET', + Uri.parse('$_apiBase/drive/v3/files/${found.id}?alt=media'), + ), + ); + if (response.statusCode != 200) { + throw _forStatus('get', key, response); + } + await destination.writeAsBytes(response.bodyBytes, flush: true); + onProgress?.call(response.bodyBytes.length, response.bodyBytes.length); + return; + } + final raf = await destination.open(mode: FileMode.write); + try { + var received = 0; + while (received < total) { + final end = min(received + chunkSizeBytes, total) - 1; + final response = await _send( + http.Request( + 'GET', + Uri.parse('$_apiBase/drive/v3/files/${found.id}?alt=media'), + )..headers['Range'] = 'bytes=$received-$end', + ); + if (response.statusCode != 206 && response.statusCode != 200) { + throw _forStatus('get range of', key, response); + } + await raf.writeFrom(response.bodyBytes); + received += response.bodyBytes.length; + onProgress?.call(received, total); + } + await raf.flush(); + } finally { + await raf.close(); + } + } + + @override + Future delete(String key) async { + final found = await _findByKey(key); + if (found == null) return; // idempotent + await _deleteById(found.id); + } + + Future _deleteById(String id) async { + final response = await _send( + http.Request('DELETE', Uri.parse('$_apiBase/drive/v3/files/$id')), + ); + if (response.statusCode != 204 && + response.statusCode != 200 && + response.statusCode != 404) { + throw _forStatus('delete', id, response); + } + } + + @override + Stream list(String keyPrefix) async* { + final folderId = await _ensureFolder(); + final files = await _query("'$folderId' in parents and trashed = false"); + for (final file in files) { + if (!file.name.startsWith(keyPrefix)) continue; + yield StoreObjectInfo( + key: file.name, + sizeBytes: file.size, + lastModified: file.modified ?? DateTime.now(), + ); + } + } + + Future _ensureFolder() async { + final cached = _folderId; + if (cached != null) return cached; + final query = + "name = '$folderName' " + "and mimeType = 'application/vnd.google-apps.folder' " + "and 'appDataFolder' in parents and trashed = false"; + final found = await _query(query); + if (found.isNotEmpty) { + return _folderId = found.first.id; + } + final response = await _send( + http.Request('POST', Uri.parse('$_apiBase/drive/v3/files')) + ..headers['Content-Type'] = 'application/json; charset=utf-8' + ..body = jsonEncode({ + 'name': folderName, + 'mimeType': 'application/vnd.google-apps.folder', + 'parents': ['appDataFolder'], + }), + ); + if (response.statusCode != 200) { + throw _forStatus('create folder for', folderName, response); + } + final decoded = jsonDecode(response.body) as Map; + return _folderId = decoded['id'] as String; + } + + Future<_DriveFile?> _findByKey(String key) async { + final folderId = await _ensureFolder(); + final escaped = key.replaceAll("'", r"\'"); + final files = await _query( + "name = '$escaped' and '$folderId' in parents and trashed = false", + ); + return files.isEmpty ? null : files.first; + } + + Future> _query(String q) async { + final uri = Uri.parse('$_apiBase/drive/v3/files').replace( + queryParameters: { + 'spaces': 'appDataFolder', + 'q': q, + 'fields': 'files(id,name,modifiedTime,size)', + }, + ); + final response = await _send(http.Request('GET', uri)); + if (response.statusCode != 200) { + throw _forStatus('query', q, response); + } + final decoded = jsonDecode(response.body) as Map; + final files = decoded['files'] as List? ?? const []; + return [ + for (final raw in files.cast>()) + _DriveFile( + id: raw['id'] as String, + name: raw['name'] as String, + size: int.tryParse(raw['size'] as String? ?? ''), + modified: DateTime.tryParse(raw['modifiedTime'] as String? ?? ''), + ), + ]; + } + + Future _send(http.Request request) async { + try { + return await http.Response.fromStream(await _client.send(request)); + } on Exception catch (e) { + throw MediaStoreException( + 'Could not reach Google Drive', + kind: MediaStoreErrorKind.transient, + cause: e, + ); + } + } + + MediaStoreException _forStatus( + String op, + String subject, + http.Response response, + ) { + final status = response.statusCode; + final MediaStoreErrorKind kind; + if (status == 401 || status == 403) { + kind = MediaStoreErrorKind.auth; + } else if (status == 404) { + kind = MediaStoreErrorKind.notFound; + } else if (status == 429 || status >= 500) { + kind = MediaStoreErrorKind.transient; + } else { + kind = MediaStoreErrorKind.fatal; + } + return MediaStoreException( + 'Drive $op $subject failed (HTTP $status)', + kind: kind, + ); + } +} + +class _DriveFile { + const _DriveFile({ + required this.id, + required this.name, + this.size, + this.modified, + }); + + final String id; + final String name; + final int? size; + final DateTime? modified; +} diff --git a/test/core/services/media_store/google_drive_media_object_store_test.dart b/test/core/services/media_store/google_drive_media_object_store_test.dart new file mode 100644 index 000000000..8088bbf05 --- /dev/null +++ b/test/core/services/media_store/google_drive_media_object_store_test.dart @@ -0,0 +1,171 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/media_store/google_drive_media_object_store.dart'; +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +import '../../../helpers/fake_drive_server.dart'; +import 'media_object_store_contract.dart'; + +void main() { + late Directory tmp; + late FakeDriveServer server; + + GoogleDriveMediaObjectStore build({int? chunkSizeBytes}) { + return GoogleDriveMediaObjectStore( + client: server.client, + chunkSizeBytes: chunkSizeBytes ?? 8 * 1024 * 1024, + apiBase: 'https://fake.googleapis.test', + ); + } + + setUp(() async { + server = FakeDriveServer(); + tmp = await Directory.systemTemp.createTemp('gdrive_mos_test'); + }); + + tearDown(() => tmp.delete(recursive: true)); + + runMediaObjectStoreContract('GoogleDriveMediaObjectStore', () async { + server = FakeDriveServer(); + return build(); + }); + + test('putFile stores the key as the file name in the media ' + 'folder', () async { + final store = build(); + final src = File('${tmp.path}/a.jpg')..writeAsBytesSync([1, 2, 3]); + await store.putFile( + 'smv1/objects/ab/abc.jpg', + src, + contentType: 'image/jpeg', + ); + expect(server.filesById.values.single.name, 'smv1/objects/ab/abc.jpg'); + expect(server.filesById.values.single.bytes, [1, 2, 3]); + expect(server.foldersByName.keys, ['submersion-media']); + }); + + test('large putFile chunks through one session with progress and resume ' + 'state', () async { + const chunk = 256 * 1024; + final store = build(chunkSizeBytes: chunk); + final bytes = List.generate(700 * 1024, (i) => i % 251); + final src = File('${tmp.path}/video.mp4')..writeAsBytesSync(bytes); + + final progress = []; + String? resumeJson; + await store.putFile( + 'smv1/objects/aa/video.mp4', + src, + contentType: 'video/mp4', + onProgress: (sent, total) => progress.add(sent), + onResumeStateChanged: (json) => resumeJson = json, + ); + + expect(server.filesById.values.single.bytes, bytes); + expect(server.chunkPutCount, 3, reason: '700KiB / 256KiB = 3 chunks'); + expect(progress.last, bytes.length); + expect(resumeJson, contains('"sessionUri"')); + }); + + test('kill-and-resume probes the session and uploads only the ' + 'tail', () async { + const chunk = 256 * 1024; + final store = build(chunkSizeBytes: chunk); + final bytes = List.generate(700 * 1024, (i) => (i * 3) % 251); + final src = File('${tmp.path}/kr.mp4')..writeAsBytesSync(bytes); + + String? resumeJson; + server.failAfterChunkPuts = 2; // chunks 1-2 land, chunk 3 dies once + await expectLater( + store.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + onResumeStateChanged: (json) => resumeJson = json, + ), + throwsA(isA()), + ); + expect(resumeJson, isNotNull); + final putsBefore = server.chunkPutCount; + expect(putsBefore, 2); + + final resumed = build(chunkSizeBytes: chunk); + await resumed.putFile( + 'smv1/objects/aa/kr.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: resumeJson, + ); + expect(server.filesById.values.single.bytes, bytes); + expect( + server.chunkPutCount - putsBefore, + 1, + reason: 'only chunk 3 uploads on resume', + ); + final probes = server.captured.where( + (r) => + r.method == 'PUT' && + (r.headers['Content-Range'] ?? '').startsWith('bytes */'), + ); + expect(probes, isNotEmpty, reason: 'resume must probe the session'); + }); + + test('stale session (404 probe) restarts fresh', () async { + const chunk = 256 * 1024; + final store = build(chunkSizeBytes: chunk); + final bytes = List.generate(300 * 1024, (i) => i % 199); + final src = File('${tmp.path}/stale.mp4')..writeAsBytesSync(bytes); + + await store.putFile( + 'smv1/objects/aa/stale.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: + '{"sessionUri":"https://fake.googleapis.test/fake-session/' + 'session-nope","totalBytes":${bytes.length},' + '"chunkSizeBytes":$chunk}', + ); + expect(server.filesById.values.single.bytes, bytes); + }); + + test('chunked getFile round-trips with progress', () async { + const chunk = 256 * 1024; + final store = build(chunkSizeBytes: chunk); + final bytes = List.generate(700 * 1024, (i) => (i * 7) % 251); + // Seed via the adapter itself (also exercises folder reuse). + final src = File('${tmp.path}/seed.bin')..writeAsBytesSync(bytes); + await store.putFile( + 'smv1/objects/aa/big.bin', + src, + contentType: 'application/octet-stream', + ); + + final dest = File('${tmp.path}/big.out'); + final progress = []; + await store.getFile( + 'smv1/objects/aa/big.bin', + dest, + onProgress: (received, total) => progress.add(received), + ); + expect(await dest.readAsBytes(), bytes); + expect(progress.length, greaterThan(1)); + expect(progress.last, bytes.length); + }); + + test('head returns null for a missing key and metadata for an existing ' + 'one', () async { + final store = build(); + expect(await store.head('smv1/objects/aa/none.jpg'), isNull); + final src = File('${tmp.path}/h.jpg') + ..writeAsBytesSync(Uint8List.fromList([5, 5])); + await store.putFile( + 'smv1/objects/aa/h.jpg', + src, + contentType: 'image/jpeg', + ); + final info = await store.head('smv1/objects/aa/h.jpg'); + expect(info!.sizeBytes, 2); + }); +} diff --git a/test/helpers/fake_drive_server.dart b/test/helpers/fake_drive_server.dart new file mode 100644 index 000000000..835cd87e5 --- /dev/null +++ b/test/helpers/fake_drive_server.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// In-memory Google Drive v3 fake for MockClient: file/folder queries, +/// resumable upload sessions with the `bytes */total` probe, Range +/// downloads. All files live in a single parent folder (the media +/// folder), matching the adapter's layout. +class FakeDriveServer { + final Map filesById = {}; + final Map foldersByName = {}; + final List captured = []; + + final Map _sessions = {}; + int _counter = 0; + + /// Session PUTs that carried body bytes and were accepted. + int chunkPutCount = 0; + + /// One-shot 500 on the chunk PUT that would take the count past this + /// value (the adapter has no retry layer, so one shot works). + int? failAfterChunkPuts; + + MockClient get client => MockClient(_handle); + + Future _handle(http.Request request) async { + captured.add(request); + final path = request.url.path; + if (path == '/drive/v3/files' && request.method == 'GET') { + return _handleQuery(request); + } + if (path == '/drive/v3/files' && request.method == 'POST') { + final body = jsonDecode(request.body) as Map; + _counter++; + final id = 'folder-$_counter'; + foldersByName[body['name'] as String] = id; + return http.Response(jsonEncode({'id': id}), 200); + } + if (path == '/upload/drive/v3/files' && request.method == 'POST') { + final body = jsonDecode(request.body) as Map; + _counter++; + final sessionId = 'session-$_counter'; + _sessions[sessionId] = _Session(name: body['name'] as String); + return http.Response( + '', + 200, + headers: {'location': '${request.url.origin}/fake-session/$sessionId'}, + ); + } + if (path.startsWith('/fake-session/') && request.method == 'PUT') { + return _handleSessionPut(request, path.split('/').last); + } + if (path.startsWith('/drive/v3/files/') && request.method == 'GET') { + final id = path.split('/').last; + final file = filesById[id]; + if (file == null) return http.Response('', 404); + final range = request.headers['Range'] ?? request.headers['range']; + if (range != null) { + final match = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(range)!; + final start = int.parse(match.group(1)!); + final end = int.parse(match.group(2)!).clamp(0, file.bytes.length - 1); + return http.Response.bytes( + file.bytes.sublist(start, end + 1), + 206, + headers: {'content-range': 'bytes $start-$end/${file.bytes.length}'}, + ); + } + return http.Response.bytes(file.bytes, 200); + } + if (path.startsWith('/drive/v3/files/') && request.method == 'DELETE') { + filesById.remove(path.split('/').last); + return http.Response('', 204); + } + return http.Response('{"error": {"message": "unknown $path"}}', 400); + } + + http.Response _handleQuery(http.Request request) { + final q = request.url.queryParameters['q'] ?? ''; + final nameMatch = RegExp(r"name = '([^']+)'").firstMatch(q); + // File-lookup queries also contain the word 'folder' (in the parents + // clause), so discriminate on the mimeType marker instead. + final wantsFolder = q.contains('vnd.google-apps.folder'); + + if (wantsFolder && nameMatch != null) { + final id = foldersByName[nameMatch.group(1)]; + return http.Response( + jsonEncode({ + 'files': [ + if (id != null) {'id': id, 'name': nameMatch.group(1)}, + ], + }), + 200, + ); + } + + final entries = filesById.entries.where( + (e) => nameMatch == null || e.value.name == nameMatch.group(1), + ); + return http.Response( + jsonEncode({ + 'files': [ + for (final e in entries) + { + 'id': e.key, + 'name': e.value.name, + 'modifiedTime': '2026-07-09T00:00:00.000Z', + 'size': '${e.value.bytes.length}', + }, + ], + }), + 200, + ); + } + + http.Response _handleSessionPut(http.Request request, String sessionId) { + final session = _sessions[sessionId]; + if (session == null) return http.Response('', 404); + final contentRange = + request.headers['Content-Range'] ?? request.headers['content-range']!; + + final probe = RegExp(r'bytes \*/(\d+)').firstMatch(contentRange); + if (probe != null) { + final stored = session.builder.length; + if (stored == 0) return http.Response('', 308); + return http.Response( + '', + 308, + headers: {'range': 'bytes=0-${stored - 1}'}, + ); + } + + final match = RegExp(r'bytes (\d+)-(\d+)/(\d+)').firstMatch(contentRange)!; + final start = int.parse(match.group(1)!); + final end = int.parse(match.group(2)!); + final total = int.parse(match.group(3)!); + if (start != session.builder.length) return http.Response('', 500); + + final limit = failAfterChunkPuts; + if (limit != null && chunkPutCount >= limit) { + failAfterChunkPuts = null; + return http.Response('', 500); + } + chunkPutCount++; + session.builder.add(request.bodyBytes); + + if (end + 1 == total) { + _sessions.remove(sessionId); + _counter++; + final id = 'file-$_counter'; + filesById[id] = (name: session.name, bytes: session.builder.toBytes()); + return http.Response(jsonEncode({'id': id, 'name': session.name}), 200); + } + return http.Response( + '', + 308, + headers: {'range': 'bytes=0-${session.builder.length - 1}'}, + ); + } +} + +class _Session { + _Session({required this.name}); + final String name; + final BytesBuilder builder = BytesBuilder(); +} From ed82e02ae1558f2e53f6554d54780038bce99740 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:43:17 -0400 Subject: [PATCH 13/18] feat(media-store): icloud media adapter over the ubiquity container --- .../icloud_media_object_store.dart | 149 ++++++++++++++++++ .../media_store/icloud_media_platform.dart | 98 ++++++++++++ .../icloud_media_object_store_test.dart | 93 +++++++++++ 3 files changed, 340 insertions(+) create mode 100644 lib/core/services/media_store/icloud_media_object_store.dart create mode 100644 lib/core/services/media_store/icloud_media_platform.dart create mode 100644 test/core/services/media_store/icloud_media_object_store_test.dart diff --git a/lib/core/services/media_store/icloud_media_object_store.dart b/lib/core/services/media_store/icloud_media_object_store.dart new file mode 100644 index 000000000..a2d0677a7 --- /dev/null +++ b/lib/core/services/media_store/icloud_media_object_store.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:submersion/core/services/media_store/icloud_media_platform.dart'; +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +/// iCloud-backed media object store (design spec section 8.2). Keys map to +/// `/[rootFolder]/`; the ubiquity container behaves as a +/// coordinated filesystem, so small objects write directly and large ones +/// move in via native file coordination. Upload/download resume and +/// progress are OS-managed: the adapter accepts the resume parameters and +/// reports a single completion tick. +class ICloudMediaObjectStore implements MediaObjectStore { + ICloudMediaObjectStore({ + required ICloudMediaPlatform platform, + this.rootFolder = 'submersion-media', + this.smallFileThresholdBytes = 8 * 1024 * 1024, + }) : _platform = platform; + + final ICloudMediaPlatform _platform; + final String rootFolder; + final int smallFileThresholdBytes; + + Future _root() async { + final container = await _platform.containerPath(); + if (container == null) { + throw const MediaStoreException( + 'iCloud is not available on this device', + kind: MediaStoreErrorKind.fatal, + ); + } + return p.join(container, rootFolder); + } + + Future _pathFor(String key) async => p.join(await _root(), key); + + @override + Future head(String key) async { + final path = await _pathFor(key); + if (!await _platform.ensureDownloaded(path)) return null; + final file = File(path); + if (!await file.exists()) return null; + final stat = await file.stat(); + return StoreObjectInfo( + key: key, + sizeBytes: stat.size, + lastModified: stat.modified, + ); + } + + @override + Future putFile( + String key, + File source, { + required String contentType, + TransferProgressCallback? onProgress, + String? resumeStateJson, + void Function(String resumeStateJson)? onResumeStateChanged, + }) async { + final path = await _pathFor(key); + final int length; + try { + length = await source.length(); + } on FileSystemException catch (e) { + throw MediaStoreException( + 'cannot read source for $key', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + await Directory(p.dirname(path)).create(recursive: true); + if (length <= smallFileThresholdBytes) { + await _platform.writeSmallFile(path, await source.readAsBytes()); + } else { + // Copy to a container-local sibling first so the coordinated move is + // same-volume, then let the OS upload in the background. + final staging = '$path.uploading'; + await source.copy(staging); + final moved = await _platform.moveIntoContainer(staging, path); + if (!moved) { + throw MediaStoreException( + 'iCloud move failed for $key', + kind: MediaStoreErrorKind.transient, + ); + } + } + onProgress?.call(length, length); + } + + @override + Future getFile( + String key, + File destination, { + TransferProgressCallback? onProgress, + }) async { + final path = await _pathFor(key); + if (!await _platform.ensureDownloaded(path)) { + throw MediaStoreException( + 'not found: $key', + kind: MediaStoreErrorKind.notFound, + ); + } + final file = File(path); + if (!await file.exists()) { + throw MediaStoreException( + 'not found: $key', + kind: MediaStoreErrorKind.notFound, + ); + } + await file.copy(destination.path); + final length = await destination.length(); + onProgress?.call(length, length); + } + + @override + Future delete(String key) async { + final path = await _pathFor(key); + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } + + @override + Stream list(String keyPrefix) async* { + final root = await _root(); + await _platform.refreshFolder(root); + final directory = Directory(root); + if (!await directory.exists()) return; + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + final key = p + .relative(entity.path, from: root) + .replaceAll(p.separator, '/'); + if (!key.startsWith(keyPrefix)) continue; + if (key.endsWith('.uploading')) continue; + final stat = await entity.stat(); + yield StoreObjectInfo( + key: key, + sizeBytes: stat.size, + lastModified: stat.modified, + ); + } + } +} diff --git a/lib/core/services/media_store/icloud_media_platform.dart b/lib/core/services/media_store/icloud_media_platform.dart new file mode 100644 index 000000000..994463ee8 --- /dev/null +++ b/lib/core/services/media_store/icloud_media_platform.dart @@ -0,0 +1,98 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +/// Thin seam over the iCloud container so the media adapter is testable +/// against a temp directory. The default implementation delegates to +/// ICloudNativeService statics plus dart:io. +abstract class ICloudMediaPlatform { + /// The container's Documents directory, or null when iCloud is + /// unavailable on this device/build. + Future containerPath(); + + /// Coordinated small-file write. + Future writeSmallFile(String path, Uint8List data); + + /// Coordinated move of a local file into the container (large files; + /// the OS uploads in the background). + Future moveIntoContainer(String sourcePath, String destinationPath); + + /// Ensures the file at [path] is materialized locally. + Future ensureDownloaded(String path); + + /// Best-effort refresh so files from other devices become visible. + Future refreshFolder(String path); +} + +/// Production implementation over the native channel. +class NativeICloudMediaPlatform implements ICloudMediaPlatform { + @override + Future containerPath() => ICloudNativeService.getContainerPath(); + + @override + Future writeSmallFile(String path, Uint8List data) async { + try { + await ICloudNativeService.writeFile(path, data); + } on Exception catch (e) { + throw MediaStoreException( + 'iCloud write failed for $path', + kind: MediaStoreErrorKind.fatal, + cause: e, + ); + } + } + + @override + Future moveIntoContainer(String sourcePath, String destinationPath) => + ICloudNativeService.moveFile(sourcePath, destinationPath); + + @override + Future ensureDownloaded(String path) => + ICloudNativeService.downloadIfNeeded(path); + + @override + Future refreshFolder(String path) => + ICloudNativeService.refreshFolder(path); +} + +/// Temp-directory fake for tests: the "container" is a plain directory. +class DirectoryICloudMediaPlatform implements ICloudMediaPlatform { + DirectoryICloudMediaPlatform(this.root); + + final Directory root; + + @override + Future containerPath() async => root.path; + + @override + Future writeSmallFile(String path, Uint8List data) async { + final file = File(path); + await file.parent.create(recursive: true); + await file.writeAsBytes(data, flush: true); + } + + @override + Future moveIntoContainer( + String sourcePath, + String destinationPath, + ) async { + final source = File(sourcePath); + if (!await source.exists()) return false; + await File(destinationPath).parent.create(recursive: true); + try { + await source.rename(destinationPath); + } on FileSystemException { + await source.copy(destinationPath); + await source.delete(); + } + return true; + } + + @override + Future ensureDownloaded(String path) => File(path).exists(); + + @override + Future refreshFolder(String path) async {} +} diff --git a/test/core/services/media_store/icloud_media_object_store_test.dart b/test/core/services/media_store/icloud_media_object_store_test.dart new file mode 100644 index 000000000..400cc042f --- /dev/null +++ b/test/core/services/media_store/icloud_media_object_store_test.dart @@ -0,0 +1,93 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/media_store/icloud_media_object_store.dart'; +import 'package:submersion/core/services/media_store/icloud_media_platform.dart'; +import 'package:submersion/core/services/media_store/media_object_store.dart'; + +import 'media_object_store_contract.dart'; + +class _NoContainerPlatform extends DirectoryICloudMediaPlatform { + _NoContainerPlatform(super.root); + + @override + Future containerPath() async => null; +} + +void main() { + late Directory container; + late Directory tmp; + + ICloudMediaObjectStore build({int? smallFileThresholdBytes}) { + return ICloudMediaObjectStore( + platform: DirectoryICloudMediaPlatform(container), + smallFileThresholdBytes: smallFileThresholdBytes ?? 8 * 1024 * 1024, + ); + } + + setUp(() async { + container = await Directory.systemTemp.createTemp('icloud_container'); + tmp = await Directory.systemTemp.createTemp('icloud_mos_test'); + }); + + tearDown(() async { + await container.delete(recursive: true); + await tmp.delete(recursive: true); + }); + + runMediaObjectStoreContract('ICloudMediaObjectStore', () async { + // Fresh container per contract test. + container = await Directory.systemTemp.createTemp('icloud_contract'); + return build(); + }); + + test('large putFile lands via moveIntoContainer and the staging copy is ' + 'gone', () async { + final store = build(smallFileThresholdBytes: 1024); + final bytes = List.generate(4096, (i) => i % 251); + final src = File('${tmp.path}/video.mp4')..writeAsBytesSync(bytes); + + final progress = []; + await store.putFile( + 'smv1/objects/aa/video.mp4', + src, + contentType: 'video/mp4', + onProgress: (sent, total) => progress.add(sent), + ); + + final landed = File( + '${container.path}/submersion-media/smv1/objects/aa/video.mp4', + ); + expect(await landed.readAsBytes(), bytes); + expect( + File( + '${container.path}/submersion-media/smv1/objects/aa/' + 'video.mp4.uploading', + ).existsSync(), + isFalse, + ); + expect(progress.single, bytes.length); + // The .uploading staging suffix never leaks into listings. + final keys = await store.list('smv1/').map((o) => o.key).toList(); + expect(keys, ['smv1/objects/aa/video.mp4']); + }); + + test('null container path maps to a fatal MediaStoreException', () async { + final store = ICloudMediaObjectStore( + platform: _NoContainerPlatform(container), + ); + final src = File('${tmp.path}/x.jpg') + ..writeAsBytesSync(Uint8List.fromList([1])); + await expectLater( + store.putFile('smv1/objects/aa/x.jpg', src, contentType: 'image/jpeg'), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + MediaStoreErrorKind.fatal, + ), + ), + ); + }); +} From bc224e5fdde9bb43817672a8da193ea69634b1eb Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:45:51 -0400 Subject: [PATCH 14/18] feat(media-store): provider-typed attach state and runtime --- .../media_store/media_store_attach_state.dart | 38 +++++++++++--- .../media_store/data/media_store_service.dart | 52 ++++++++++++++++++- .../providers/media_store_providers.dart | 21 ++++---- .../media_store_credentials_test.dart | 26 +++++++++- 4 files changed, 117 insertions(+), 20 deletions(-) diff --git a/lib/core/services/media_store/media_store_attach_state.dart b/lib/core/services/media_store/media_store_attach_state.dart index 5769852db..b6dd3fd52 100644 --- a/lib/core/services/media_store/media_store_attach_state.dart +++ b/lib/core/services/media_store/media_store_attach_state.dart @@ -1,15 +1,20 @@ import 'package:shared_preferences/shared_preferences.dart'; -/// Which media store this device is attached to. Secret-free; credentials -/// live in the keychain (MediaStoreCredentialsStore). SharedPreferences so -/// a database restore cannot silently re-point the device at a different -/// store (same reasoning as the library-epoch mirror). +import 'package:submersion/core/data/repositories/sync_repository.dart'; + +/// Which media store this device is attached to, and through which +/// provider. Secret-free; credentials live in the keychain +/// (MediaStoreCredentialsStore) or the providers' own auth stores. +/// SharedPreferences so a database restore cannot silently re-point the +/// device at a different store (same reasoning as the library-epoch +/// mirror). class MediaStoreAttachState { MediaStoreAttachState({SharedPreferences? prefs}) : _prefs = prefs; final SharedPreferences? _prefs; static const String storeIdKey = 'media_store_attached_store_id'; + static const String providerTypeKey = 'media_store_provider_type'; Future get _resolved async => _prefs ?? await SharedPreferences.getInstance(); @@ -17,10 +22,29 @@ class MediaStoreAttachState { Future attachedStoreId() async => (await _resolved).getString(storeIdKey); - Future setAttached(String storeId) async => - (await _resolved).setString(storeIdKey, storeId); + /// The attached provider, or null when no store is attached. Attachments + /// persisted before the provider type existed read as S3 (the only + /// option back then) - no migration needed. + Future attachedProviderType() async { + final prefs = await _resolved; + if (prefs.getString(storeIdKey) == null) return null; + final stored = prefs.getString(providerTypeKey); + if (stored == null) return CloudProviderType.s3; + return CloudProviderType.values.byName(stored); + } + + Future setAttached( + String storeId, { + required CloudProviderType providerType, + }) async { + final prefs = await _resolved; + await prefs.setString(storeIdKey, storeId); + await prefs.setString(providerTypeKey, providerType.name); + } Future clear() async { - await (await _resolved).remove(storeIdKey); + final prefs = await _resolved; + await prefs.remove(storeIdKey); + await prefs.remove(providerTypeKey); } } diff --git a/lib/features/media_store/data/media_store_service.dart b/lib/features/media_store/data/media_store_service.dart index 2582d7390..351d0cf94 100644 --- a/lib/features/media_store/data/media_store_service.dart +++ b/lib/features/media_store/data/media_store_service.dart @@ -1,13 +1,60 @@ import 'dart:io'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_auth_manager.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_api_client.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; +import 'package:submersion/core/services/media_store/dropbox_media_object_store.dart'; +import 'package:submersion/core/services/media_store/google_drive_media_object_store.dart'; +import 'package:submersion/core/services/media_store/icloud_media_object_store.dart'; +import 'package:submersion/core/services/media_store/icloud_media_platform.dart'; import 'package:submersion/core/services/media_store/media_object_store.dart'; import 'package:submersion/core/services/media_store/media_store_attach_state.dart'; import 'package:submersion/core/services/media_store/media_store_credentials_store.dart'; import 'package:submersion/core/services/media_store/s3_media_object_store.dart'; import 'package:submersion/core/services/media_store/store_marker.dart'; import 'package:submersion/features/media_store/data/media_stores_repository.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; + +/// Builds the store adapter for [type], or null when the provider is not +/// usable right now (missing config, no silent Google session, iCloud +/// unavailable). Shared by the runtime provider and the connect flows. +Future buildMediaObjectStore( + CloudProviderType type, { + S3Config? s3Config, +}) async { + switch (type) { + case CloudProviderType.s3: + if (s3Config == null) return null; + return S3MediaObjectStore( + client: S3ApiClient(s3Config), + keyPrefix: s3Config.prefix, + ); + case CloudProviderType.dropbox: + final auth = DropboxAuthManager(); + if (await auth.loadAuth() == null) return null; + return DropboxMediaObjectStore( + client: DropboxApiClient( + getAccessToken: auth.getAccessToken, + onAccessTokenRejected: auth.invalidateAccessToken, + ), + ); + case CloudProviderType.googledrive: + final provider = + cloudProviderInstanceFor(CloudProviderType.googledrive) + as GoogleDriveStorageProvider; + final client = await provider.mediaHttpClient(); + if (client == null) return null; + return GoogleDriveMediaObjectStore(client: client); + case CloudProviderType.icloud: + final availability = await ICloudNativeService.getAvailability(); + if (availability != ICloudAvailability.available) return null; + return ICloudMediaObjectStore(platform: NativeICloudMediaPlatform()); + } +} class MediaStoreConnectResult { final String storeId; @@ -76,7 +123,10 @@ class MediaStoreService { final store = _storeFactory(config); final ensured = await StoreMarkerStore(store: store).ensure(); await _credentials.save(config); - await _attachState.setAttached(ensured.marker.storeId); + await _attachState.setAttached( + ensured.marker.storeId, + providerType: CloudProviderType.s3, + ); await _storesRepository.upsertActive( storeId: ensured.marker.storeId, providerType: 's3', diff --git a/lib/features/media_store/presentation/providers/media_store_providers.dart b/lib/features/media_store/presentation/providers/media_store_providers.dart index 16c2de750..f1131e303 100644 --- a/lib/features/media_store/presentation/providers/media_store_providers.dart +++ b/lib/features/media_store/presentation/providers/media_store_providers.dart @@ -5,14 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:submersion/core/services/cloud_storage/s3/s3_api_client.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/services/local_cache_database_service.dart'; import 'package:submersion/core/services/media_store/media_object_store.dart'; import 'package:submersion/core/services/media_store/media_store_attach_state.dart'; import 'package:submersion/core/services/media_store/media_store_credentials_store.dart'; import 'package:submersion/core/services/media_store/media_store_policies.dart'; import 'package:submersion/core/services/media_store/network_status_service.dart'; -import 'package:submersion/core/services/media_store/s3_media_object_store.dart'; import 'package:submersion/core/services/media_store/store_marker.dart'; import 'package:submersion/features/media/data/resolvers/media_store_resolver.dart'; import 'package:submersion/features/media/domain/entities/media_item.dart'; @@ -128,16 +127,16 @@ final mediaStoreServiceProvider = Provider( final mediaStoreRuntimeProvider = FutureProvider(( ref, ) async { - final config = await ref.watch(mediaStoreCredentialsStoreProvider).load(); - if (config == null) return null; - final attachedId = await ref - .watch(mediaStoreAttachStateProvider) - .attachedStoreId(); + final attachState = ref.watch(mediaStoreAttachStateProvider); + final attachedId = await attachState.attachedStoreId(); if (attachedId == null) return null; - - final client = S3ApiClient(config); - ref.onDispose(client.close); - final store = S3MediaObjectStore(client: client, keyPrefix: config.prefix); + final providerType = + await attachState.attachedProviderType() ?? CloudProviderType.s3; + final s3Config = providerType == CloudProviderType.s3 + ? await ref.watch(mediaStoreCredentialsStoreProvider).load() + : null; + final store = await buildMediaObjectStore(providerType, s3Config: s3Config); + if (store == null) return null; final supportDir = await getApplicationSupportDirectory(); final cache = MediaCacheStore( diff --git a/test/core/services/media_store/media_store_credentials_test.dart b/test/core/services/media_store/media_store_credentials_test.dart index bde2b4ed1..be4110188 100644 --- a/test/core/services/media_store/media_store_credentials_test.dart +++ b/test/core/services/media_store/media_store_credentials_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; import 'package:submersion/core/services/media_store/media_store_attach_state.dart'; import 'package:submersion/core/services/media_store/media_store_credentials_store.dart'; @@ -37,9 +38,32 @@ void main() { final prefs = await SharedPreferences.getInstance(); final state = MediaStoreAttachState(prefs: prefs); expect(await state.attachedStoreId(), isNull); - await state.setAttached('store-xyz'); + await state.setAttached('store-xyz', providerType: CloudProviderType.s3); expect(await state.attachedStoreId(), 'store-xyz'); await state.clear(); expect(await state.attachedStoreId(), isNull); }); + + test('attach state records and returns the provider type', () async { + SharedPreferences.setMockInitialValues({}); + final state = MediaStoreAttachState( + prefs: await SharedPreferences.getInstance(), + ); + await state.setAttached('store-1', providerType: CloudProviderType.dropbox); + expect(await state.attachedStoreId(), 'store-1'); + expect(await state.attachedProviderType(), CloudProviderType.dropbox); + await state.clear(); + expect(await state.attachedProviderType(), isNull); + }); + + test('a pre-phase-4 attachment without a provider type reads as ' + 'S3', () async { + SharedPreferences.setMockInitialValues({ + MediaStoreAttachState.storeIdKey: 'store-legacy', + }); + final state = MediaStoreAttachState( + prefs: await SharedPreferences.getInstance(), + ); + expect(await state.attachedProviderType(), CloudProviderType.s3); + }); } From 085f37abe2d41b0610d645393bbb57261bd480d8 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:47:20 -0400 Subject: [PATCH 15/18] feat(media-store): per-provider connect flows --- .../media_store/data/media_store_service.dart | 64 ++++++++++++++++++- .../media_store/media_store_service_test.dart | 59 +++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/lib/features/media_store/data/media_store_service.dart b/lib/features/media_store/data/media_store_service.dart index 351d0cf94..2cd0484e2 100644 --- a/lib/features/media_store/data/media_store_service.dart +++ b/lib/features/media_store/data/media_store_service.dart @@ -75,15 +75,30 @@ class MediaStoreService { required MediaStoreAttachState attachState, required MediaStoresRepository storesRepository, MediaObjectStore Function(S3Config config)? storeFactory, + Future Function()? dropboxStoreFactory, + Future Function()? googleDriveStoreFactory, + Future Function()? icloudStoreFactory, }) : _credentials = credentials, _attachState = attachState, _storesRepository = storesRepository, - _storeFactory = storeFactory ?? _defaultStoreFactory; + _storeFactory = storeFactory ?? _defaultStoreFactory, + _dropboxStoreFactory = + dropboxStoreFactory ?? + (() => buildMediaObjectStore(CloudProviderType.dropbox)), + _googleDriveStoreFactory = + googleDriveStoreFactory ?? + (() => buildMediaObjectStore(CloudProviderType.googledrive)), + _icloudStoreFactory = + icloudStoreFactory ?? + (() => buildMediaObjectStore(CloudProviderType.icloud)); final MediaStoreCredentialsStore _credentials; final MediaStoreAttachState _attachState; final MediaStoresRepository _storesRepository; final MediaObjectStore Function(S3Config config) _storeFactory; + final Future Function() _dropboxStoreFactory; + final Future Function() _googleDriveStoreFactory; + final Future Function() _icloudStoreFactory; static MediaObjectStore _defaultStoreFactory(S3Config config) => S3MediaObjectStore(client: S3ApiClient(config), keyPrefix: config.prefix); @@ -138,6 +153,53 @@ class MediaStoreService { ); } + /// Connects the media store through the user's Dropbox link (made in + /// Cloud Sync settings); media lives in the Dropbox app folder. + Future connectDropbox() => _connectManaged( + CloudProviderType.dropbox, + 'Dropbox', + _dropboxStoreFactory, + ); + + /// Connects through the Google account session; media lives in this + /// app's private Drive space. + Future connectGoogleDrive() => _connectManaged( + CloudProviderType.googledrive, + 'Google Drive', + _googleDriveStoreFactory, + ); + + /// Connects through the signed-in Apple ID's iCloud container. + Future connectICloud() => + _connectManaged(CloudProviderType.icloud, 'iCloud', _icloudStoreFactory); + + /// Shared managed-provider flow: no credentials-store write - managed + /// providers keep credentials in their own auth stores. + Future _connectManaged( + CloudProviderType type, + String displayHint, + Future Function() factory, + ) async { + final store = await factory(); + if (store == null) { + throw MediaStoreException( + '$displayHint is not connected or unavailable on this device', + kind: MediaStoreErrorKind.auth, + ); + } + final ensured = await StoreMarkerStore(store: store).ensure(); + await _attachState.setAttached(ensured.marker.storeId, providerType: type); + await _storesRepository.upsertActive( + storeId: ensured.marker.storeId, + providerType: type.name, + displayHint: displayHint, + ); + return MediaStoreConnectResult( + storeId: ensured.marker.storeId, + createdNewStore: ensured.created, + ); + } + /// Detaches this device. Credentials and attach state are cleared; the /// synced descriptor row and everything in the bucket remain. Future disconnect() async { diff --git a/test/features/media_store/media_store_service_test.dart b/test/features/media_store/media_store_service_test.dart index 765df9b86..09496d3d6 100644 --- a/test/features/media_store/media_store_service_test.dart +++ b/test/features/media_store/media_store_service_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/services/media_store/media_object_store.dart'; import 'package:submersion/core/services/media_store/media_store_attach_state.dart'; import 'package:submersion/core/services/media_store/media_store_credentials_store.dart'; @@ -92,4 +93,62 @@ void main() { expect(await credentials.load(), isNull); expect(await attachState.attachedStoreId(), isNull); }); + + test('connectDropbox ensures the marker and records provider ' + 'type', () async { + final dropboxFake = InMemoryMediaObjectStore(); + final svc = MediaStoreService( + credentials: credentials, + attachState: attachState, + storesRepository: storesRepository, + dropboxStoreFactory: () async => dropboxFake, + ); + final result = await svc.connectDropbox(); + expect(result.createdNewStore, isTrue); + expect(dropboxFake.objects.containsKey('smv1/store.json'), isTrue); + expect(await attachState.attachedProviderType(), CloudProviderType.dropbox); + final active = await storesRepository.getActive(); + expect(active!.providerType, 'dropbox'); + expect( + await credentials.load(), + isNull, + reason: 'managed providers never touch the S3 keychain entry', + ); + }); + + test('connectICloud ensures the marker and records provider ' + 'type', () async { + final icloudFake = InMemoryMediaObjectStore(); + final svc = MediaStoreService( + credentials: credentials, + attachState: attachState, + storesRepository: storesRepository, + icloudStoreFactory: () async => icloudFake, + ); + final result = await svc.connectICloud(); + expect(icloudFake.objects.containsKey('smv1/store.json'), isTrue); + expect(await attachState.attachedProviderType(), CloudProviderType.icloud); + expect((await storesRepository.getActive())!.displayHint, 'iCloud'); + expect(result.storeId, isNotEmpty); + }); + + test('connect on an unavailable managed provider throws auth', () async { + final svc = MediaStoreService( + credentials: credentials, + attachState: attachState, + storesRepository: storesRepository, + googleDriveStoreFactory: () async => null, + ); + await expectLater( + svc.connectGoogleDrive(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + MediaStoreErrorKind.auth, + ), + ), + ); + expect(await attachState.attachedStoreId(), isNull); + }); } From 1fbacef375a453aa158223bc73cb7960c0e88391 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:56:08 -0400 Subject: [PATCH 16/18] feat(media-store): provider chooser with dropbox, drive, icloud connect --- .../pages/media_storage_page.dart | 400 +++++++++++------- lib/l10n/arb/app_ar.arb | 7 +- lib/l10n/arb/app_de.arb | 7 +- lib/l10n/arb/app_en.arb | 14 +- lib/l10n/arb/app_es.arb | 7 +- lib/l10n/arb/app_fr.arb | 7 +- lib/l10n/arb/app_he.arb | 7 +- lib/l10n/arb/app_hu.arb | 7 +- lib/l10n/arb/app_it.arb | 7 +- lib/l10n/arb/app_localizations.dart | 30 ++ lib/l10n/arb/app_localizations_ar.dart | 20 + lib/l10n/arb/app_localizations_de.dart | 20 + lib/l10n/arb/app_localizations_en.dart | 20 + lib/l10n/arb/app_localizations_es.dart | 20 + lib/l10n/arb/app_localizations_fr.dart | 20 + lib/l10n/arb/app_localizations_he.dart | 20 + lib/l10n/arb/app_localizations_hu.dart | 20 + lib/l10n/arb/app_localizations_it.dart | 20 + lib/l10n/arb/app_localizations_nl.dart | 20 + lib/l10n/arb/app_localizations_pt.dart | 20 + lib/l10n/arb/app_localizations_zh.dart | 20 + lib/l10n/arb/app_nl.arb | 7 +- lib/l10n/arb/app_pt.arb | 7 +- lib/l10n/arb/app_zh.arb | 7 +- .../media_store/media_storage_page_test.dart | 90 +++- 25 files changed, 654 insertions(+), 170 deletions(-) diff --git a/lib/features/media_store/presentation/pages/media_storage_page.dart b/lib/features/media_store/presentation/pages/media_storage_page.dart index f9aca29d6..dc578f124 100644 --- a/lib/features/media_store/presentation/pages/media_storage_page.dart +++ b/lib/features/media_store/presentation/pages/media_storage_page.dart @@ -3,12 +3,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_credentials_store.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_region.dart'; import 'package:submersion/core/services/media_store/media_object_store.dart'; +import 'package:submersion/features/media_store/data/media_store_service.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// Configuration page for the media store's S3 backend (design spec @@ -37,6 +41,7 @@ class _MediaStoragePageState extends ConsumerState { bool _secretVisible = false; bool _busy = false; bool _syncConfigAvailable = false; + CloudProviderType _selectedProvider = CloudProviderType.s3; // Null until loaded; the switches render only once values are known. bool? _autoUpload; bool? _photosOnCellular; @@ -234,6 +239,56 @@ class _MediaStoragePageState extends ConsumerState { } } + List _managedConnectPanel(AppLocalizations l10n) { + final (hint, label, key, call) = switch (_selectedProvider) { + CloudProviderType.dropbox => ( + l10n.settings_mediaStorage_connect_dropbox_hint, + 'Dropbox', + const Key('media-dropbox-connect'), + () => ref.read(mediaStoreServiceProvider).connectDropbox(), + ), + CloudProviderType.googledrive => ( + l10n.settings_mediaStorage_connect_gdrive_hint, + 'Google Drive', + const Key('media-gdrive-connect'), + () => ref.read(mediaStoreServiceProvider).connectGoogleDrive(), + ), + _ => ( + l10n.settings_mediaStorage_connect_icloud_hint, + 'iCloud', + const Key('media-icloud-connect'), + () => ref.read(mediaStoreServiceProvider).connectICloud(), + ), + }; + return [ + Text(hint), + const SizedBox(height: 16), + FilledButton( + key: key, + onPressed: _busy ? null : () => _connectManagedFlow(call), + child: Text(l10n.settings_mediaStorage_connect_action(label)), + ), + ]; + } + + Future _connectManagedFlow( + Future Function() call, + ) async { + final l10n = context.l10n; + setState(() => _busy = true); + try { + await call(); + ref.invalidate(mediaStoreRuntimeProvider); + if (!mounted) return; + _showSnack(l10n.settings_mediaStorage_saved); + await Navigator.maybePop(context); + } on MediaStoreException catch (e) { + _showSnack(e.message, isError: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + Future _disconnect() async { final l10n = context.l10n; final confirmed = await showDialog( @@ -295,174 +350,211 @@ class _MediaStoragePageState extends ConsumerState { ), ), const SizedBox(height: 12), - if (_isInsecureEndpoint) - Card( - key: const Key('media-s3-http-warning'), - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.lock_open, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Expanded( - child: Text(l10n.settings_s3Config_warning_http), - ), - ], + if (!connected) ...[ + Text( + l10n.settings_mediaStorage_provider_label, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + SegmentedButton( + key: const Key('media-provider-chooser'), + segments: [ + const ButtonSegment( + value: CloudProviderType.s3, + label: Text('S3'), ), - ), + const ButtonSegment( + value: CloudProviderType.dropbox, + label: Text('Dropbox'), + ), + const ButtonSegment( + value: CloudProviderType.googledrive, + label: Text('Google Drive'), + ), + if (ref.watch(isApplePlatformProvider)) + const ButtonSegment( + value: CloudProviderType.icloud, + label: Text('iCloud'), + ), + ], + selected: {_selectedProvider}, + onSelectionChanged: (selection) => + setState(() => _selectedProvider = selection.single), ), - if (_syncConfigAvailable) - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - key: const Key('media-s3-copy-from-sync'), - onPressed: _busy ? null : _copyFromSync, - icon: const Icon(Icons.copy_all), - label: Text(l10n.settings_mediaStorage_action_copyFromSync), + const SizedBox(height: 16), + if (_selectedProvider != CloudProviderType.s3) + ..._managedConnectPanel(l10n), + ], + if (!connected && _selectedProvider == CloudProviderType.s3) ...[ + if (_isInsecureEndpoint) + Card( + key: const Key('media-s3-http-warning'), + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.lock_open, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text(l10n.settings_s3Config_warning_http), + ), + ], + ), + ), ), + if (_syncConfigAvailable) + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const Key('media-s3-copy-from-sync'), + onPressed: _busy ? null : _copyFromSync, + icon: const Icon(Icons.copy_all), + label: Text(l10n.settings_mediaStorage_action_copyFromSync), + ), + ), + TextFormField( + key: const Key('media-s3-endpoint'), + controller: _endpointController, + decoration: InputDecoration( + labelText: l10n.settings_s3Config_field_endpoint_label, + hintText: l10n.settings_s3Config_field_endpoint_helper, + ), + keyboardType: TextInputType.url, + autocorrect: false, + validator: (value) { + final trimmed = (value ?? '').trim(); + if (trimmed.isEmpty) { + return l10n.settings_s3Config_validation_required; + } + final uri = Uri.tryParse(trimmed); + final valid = + uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; + if (!valid) { + return l10n.settings_s3Config_validation_endpointInvalid; + } + if (uri.path.isNotEmpty && uri.path != '/') { + return l10n.settings_s3Config_validation_endpointPath; + } + return null; + }, ), - TextFormField( - key: const Key('media-s3-endpoint'), - controller: _endpointController, - decoration: InputDecoration( - labelText: l10n.settings_s3Config_field_endpoint_label, - hintText: l10n.settings_s3Config_field_endpoint_helper, - ), - keyboardType: TextInputType.url, - autocorrect: false, - validator: (value) { - final trimmed = (value ?? '').trim(); - if (trimmed.isEmpty) { - return l10n.settings_s3Config_validation_required; - } - final uri = Uri.tryParse(trimmed); - final valid = - uri != null && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.host.isNotEmpty; - if (!valid) { - return l10n.settings_s3Config_validation_endpointInvalid; - } - if (uri.path.isNotEmpty && uri.path != '/') { - return l10n.settings_s3Config_validation_endpointPath; - } - return null; - }, - ), - const SizedBox(height: 12), - TextFormField( - key: const Key('media-s3-bucket'), - controller: _bucketController, - decoration: InputDecoration( - labelText: l10n.settings_s3Config_field_bucket_label, + const SizedBox(height: 12), + TextFormField( + key: const Key('media-s3-bucket'), + controller: _bucketController, + decoration: InputDecoration( + labelText: l10n.settings_s3Config_field_bucket_label, + ), + autocorrect: false, + validator: (value) => (value ?? '').trim().isEmpty + ? l10n.settings_s3Config_validation_required + : null, ), - autocorrect: false, - validator: (value) => (value ?? '').trim().isEmpty - ? l10n.settings_s3Config_validation_required - : null, - ), - const SizedBox(height: 12), - TextFormField( - key: const Key('media-s3-access-key'), - controller: _accessKeyController, - decoration: InputDecoration( - labelText: l10n.settings_s3Config_field_accessKeyId_label, + const SizedBox(height: 12), + TextFormField( + key: const Key('media-s3-access-key'), + controller: _accessKeyController, + decoration: InputDecoration( + labelText: l10n.settings_s3Config_field_accessKeyId_label, + ), + autocorrect: false, + enableSuggestions: false, + validator: (value) => (value ?? '').trim().isEmpty + ? l10n.settings_s3Config_validation_required + : null, ), - autocorrect: false, - enableSuggestions: false, - validator: (value) => (value ?? '').trim().isEmpty - ? l10n.settings_s3Config_validation_required - : null, - ), - const SizedBox(height: 12), - TextFormField( - key: const Key('media-s3-secret-key'), - controller: _secretKeyController, - decoration: InputDecoration( - labelText: l10n.settings_s3Config_field_secretAccessKey_label, - suffixIcon: IconButton( - icon: Icon( - _secretVisible ? Icons.visibility_off : Icons.visibility, + const SizedBox(height: 12), + TextFormField( + key: const Key('media-s3-secret-key'), + controller: _secretKeyController, + decoration: InputDecoration( + labelText: l10n.settings_s3Config_field_secretAccessKey_label, + suffixIcon: IconButton( + icon: Icon( + _secretVisible ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => + setState(() => _secretVisible = !_secretVisible), ), - onPressed: () => - setState(() => _secretVisible = !_secretVisible), ), + obscureText: !_secretVisible, + autocorrect: false, + enableSuggestions: false, + validator: (value) => (value ?? '').trim().isEmpty + ? l10n.settings_s3Config_validation_required + : null, ), - obscureText: !_secretVisible, - autocorrect: false, - enableSuggestions: false, - validator: (value) => (value ?? '').trim().isEmpty - ? l10n.settings_s3Config_validation_required - : null, - ), - ExpansionTile( - key: const Key('media-s3-advanced'), - title: Text(l10n.settings_s3Config_advanced_title), - tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(top: 12, bottom: 8), - shape: const Border(), - collapsedShape: const Border(), - children: [ - TextFormField( - key: const Key('media-s3-region'), - controller: _regionController, - decoration: InputDecoration( - labelText: l10n.settings_s3Config_field_region_label, - helperText: _regionController.text.trim().isEmpty - ? l10n.settings_s3Config_field_region_helperAuto( - deriveRegion(_endpointController.text), - ) - : null, + ExpansionTile( + key: const Key('media-s3-advanced'), + title: Text(l10n.settings_s3Config_advanced_title), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(top: 12, bottom: 8), + shape: const Border(), + collapsedShape: const Border(), + children: [ + TextFormField( + key: const Key('media-s3-region'), + controller: _regionController, + decoration: InputDecoration( + labelText: l10n.settings_s3Config_field_region_label, + helperText: _regionController.text.trim().isEmpty + ? l10n.settings_s3Config_field_region_helperAuto( + deriveRegion(_endpointController.text), + ) + : null, + ), + autocorrect: false, ), - autocorrect: false, - ), - const SizedBox(height: 12), - TextFormField( - key: const Key('media-s3-prefix'), - controller: _prefixController, - decoration: InputDecoration( - labelText: l10n.settings_s3Config_field_prefix_label, + const SizedBox(height: 12), + TextFormField( + key: const Key('media-s3-prefix'), + controller: _prefixController, + decoration: InputDecoration( + labelText: l10n.settings_s3Config_field_prefix_label, + ), + autocorrect: false, ), - autocorrect: false, - ), - SwitchListTile( - key: const Key('media-s3-path-style'), - title: Text(l10n.settings_s3Config_field_pathStyle_label), - subtitle: Text( - l10n.settings_s3Config_field_pathStyle_subtitle, + SwitchListTile( + key: const Key('media-s3-path-style'), + title: Text(l10n.settings_s3Config_field_pathStyle_label), + subtitle: Text( + l10n.settings_s3Config_field_pathStyle_subtitle, + ), + value: _pathStyle, + onChanged: (value) => setState(() { + _pathStyle = value; + _pathStyleTouched = true; + }), ), - value: _pathStyle, - onChanged: (value) => setState(() { - _pathStyle = value; - _pathStyleTouched = true; - }), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton( - key: const Key('media-s3-test'), - onPressed: _busy ? null : _testConnection, - child: Text(l10n.settings_s3Config_action_testConnection), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + key: const Key('media-s3-test'), + onPressed: _busy ? null : _testConnection, + child: Text(l10n.settings_s3Config_action_testConnection), + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton( - key: const Key('media-s3-connect'), - onPressed: _busy ? null : _connect, - child: Text(l10n.common_action_save), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + key: const Key('media-s3-connect'), + onPressed: _busy ? null : _connect, + child: Text(l10n.common_action_save), + ), ), - ), - ], - ), + ], + ), + ], if (connected) ...[ const SizedBox(height: 8), if (_autoUpload != null) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 64386cb6c..2aed5ad89 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "رفع المكتبة الحالية", "settings_mediaStorage_backfill_enqueued": "{count} عمليات رفع في قائمة الانتظار", "settings_mediaStorage_policy_autoUpload": "رفع الصور تلقائيا", - "settings_mediaStorage_policy_photosOnCellular": "رفع الصور عبر شبكة الجوال" + "settings_mediaStorage_policy_photosOnCellular": "رفع الصور عبر شبكة الجوال", + "settings_mediaStorage_provider_label": "المزود", + "settings_mediaStorage_connect_dropbox_hint": "يستخدم اتصال Dropbox من مزامنة السحابة. تُخزن الوسائط في مجلد التطبيق في Dropbox.", + "settings_mediaStorage_connect_gdrive_hint": "يسجل الدخول عبر Google. تُخزن الوسائط في مساحة Drive الخاصة بهذا التطبيق.", + "settings_mediaStorage_connect_icloud_hint": "تُخزن الوسائط في حاوية iCloud لهذا التطبيق وتتزامن عبر Apple ID الخاص بك.", + "settings_mediaStorage_connect_action": "توصيل {provider}" } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 0b6bc32c9..9e3004319 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Vorhandene Bibliothek hochladen", "settings_mediaStorage_backfill_enqueued": "{count} Uploads eingereiht", "settings_mediaStorage_policy_autoUpload": "Fotos automatisch hochladen", - "settings_mediaStorage_policy_photosOnCellular": "Fotos über Mobilfunk hochladen" + "settings_mediaStorage_policy_photosOnCellular": "Fotos über Mobilfunk hochladen", + "settings_mediaStorage_provider_label": "Anbieter", + "settings_mediaStorage_connect_dropbox_hint": "Verwendet Ihre Dropbox-Verbindung aus der Cloud-Synchronisierung. Medien werden im Dropbox-App-Ordner gespeichert.", + "settings_mediaStorage_connect_gdrive_hint": "Meldet sich mit Google an. Medien werden im privaten Drive-Bereich dieser App gespeichert.", + "settings_mediaStorage_connect_icloud_hint": "Medien werden im iCloud-Container dieser App gespeichert und über Ihre Apple-ID synchronisiert.", + "settings_mediaStorage_connect_action": "{provider} verbinden" } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 059facb77..2a59c5123 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -11656,5 +11656,17 @@ } }, "settings_mediaStorage_policy_autoUpload": "Upload photos automatically", - "settings_mediaStorage_policy_photosOnCellular": "Upload photos on cellular" + "settings_mediaStorage_policy_photosOnCellular": "Upload photos on cellular", + "settings_mediaStorage_provider_label": "Provider", + "settings_mediaStorage_connect_dropbox_hint": "Uses your Dropbox connection from Cloud Sync. Media is stored in your Dropbox app folder.", + "settings_mediaStorage_connect_gdrive_hint": "Signs in with Google. Media is stored in this app's private Drive space.", + "settings_mediaStorage_connect_icloud_hint": "Media is stored in this app's iCloud container and syncs through your Apple ID.", + "settings_mediaStorage_connect_action": "Connect {provider}", + "@settings_mediaStorage_connect_action": { + "placeholders": { + "provider": { + "type": "String" + } + } + } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index ad40dcdc4..db592d649 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Subir biblioteca existente", "settings_mediaStorage_backfill_enqueued": "{count} subidas en cola", "settings_mediaStorage_policy_autoUpload": "Subir fotos automáticamente", - "settings_mediaStorage_policy_photosOnCellular": "Subir fotos con datos móviles" + "settings_mediaStorage_policy_photosOnCellular": "Subir fotos con datos móviles", + "settings_mediaStorage_provider_label": "Proveedor", + "settings_mediaStorage_connect_dropbox_hint": "Usa tu conexión de Dropbox de la sincronización en la nube. Los medios se guardan en la carpeta de la app en Dropbox.", + "settings_mediaStorage_connect_gdrive_hint": "Inicia sesión con Google. Los medios se guardan en el espacio privado de Drive de esta app.", + "settings_mediaStorage_connect_icloud_hint": "Los medios se guardan en el contenedor de iCloud de esta app y se sincronizan con tu Apple ID.", + "settings_mediaStorage_connect_action": "Conectar {provider}" } diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index f9897f476..ae8b65942 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Envoyer la bibliothèque existante", "settings_mediaStorage_backfill_enqueued": "{count} envois en file", "settings_mediaStorage_policy_autoUpload": "Envoyer les photos automatiquement", - "settings_mediaStorage_policy_photosOnCellular": "Envoyer les photos en cellulaire" + "settings_mediaStorage_policy_photosOnCellular": "Envoyer les photos en cellulaire", + "settings_mediaStorage_provider_label": "Fournisseur", + "settings_mediaStorage_connect_dropbox_hint": "Utilise votre connexion Dropbox de la synchronisation cloud. Les médias sont stockés dans le dossier d'application Dropbox.", + "settings_mediaStorage_connect_gdrive_hint": "Connexion avec Google. Les médias sont stockés dans l'espace Drive privé de cette application.", + "settings_mediaStorage_connect_icloud_hint": "Les médias sont stockés dans le conteneur iCloud de cette application et se synchronisent via votre identifiant Apple.", + "settings_mediaStorage_connect_action": "Connecter {provider}" } diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index b70bf293a..8d2aef75c 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "העלה ספריה קיימת", "settings_mediaStorage_backfill_enqueued": "{count} העלאות בתור", "settings_mediaStorage_policy_autoUpload": "העלה תמונות אוטומטית", - "settings_mediaStorage_policy_photosOnCellular": "העלה תמונות ברשת סלולרית" + "settings_mediaStorage_policy_photosOnCellular": "העלה תמונות ברשת סלולרית", + "settings_mediaStorage_provider_label": "ספק", + "settings_mediaStorage_connect_dropbox_hint": "משתמש בחיבור Dropbox מסנכרון הענן. המדיה נשמרת בתיקיית האפליקציה ב-Dropbox.", + "settings_mediaStorage_connect_gdrive_hint": "מתחבר עם Google. המדיה נשמרת בשטח ה-Drive הפרטי של האפליקציה.", + "settings_mediaStorage_connect_icloud_hint": "המדיה נשמרת במיכל ה-iCloud של האפליקציה ומסתנכרנת דרך ה-Apple ID שלך.", + "settings_mediaStorage_connect_action": "חבר את {provider}" } diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index b0789d0e9..ca56d8a68 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Meglévő könyvtár feltöltése", "settings_mediaStorage_backfill_enqueued": "{count} feltöltés sorban", "settings_mediaStorage_policy_autoUpload": "Fotók automatikus feltöltése", - "settings_mediaStorage_policy_photosOnCellular": "Fotók feltöltése mobilhálózaton" + "settings_mediaStorage_policy_photosOnCellular": "Fotók feltöltése mobilhálózaton", + "settings_mediaStorage_provider_label": "Szolgáltató", + "settings_mediaStorage_connect_dropbox_hint": "A felhőszinkronizálás Dropbox-kapcsolatát használja. A média a Dropbox alkalmazásmappájában tárolódik.", + "settings_mediaStorage_connect_gdrive_hint": "Google-fiókkal jelentkezik be. A média az alkalmazás privát Drive-területén tárolódik.", + "settings_mediaStorage_connect_icloud_hint": "A média az alkalmazás iCloud-tárolójában tárolódik, és az Apple ID-n keresztül szinkronizálódik.", + "settings_mediaStorage_connect_action": "{provider} csatlakoztatása" } diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index c52aaa0b2..dd965b47e 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Carica libreria esistente", "settings_mediaStorage_backfill_enqueued": "{count} caricamenti in coda", "settings_mediaStorage_policy_autoUpload": "Carica foto automaticamente", - "settings_mediaStorage_policy_photosOnCellular": "Carica foto su rete mobile" + "settings_mediaStorage_policy_photosOnCellular": "Carica foto su rete mobile", + "settings_mediaStorage_provider_label": "Provider", + "settings_mediaStorage_connect_dropbox_hint": "Usa la tua connessione Dropbox della sincronizzazione cloud. I media sono archiviati nella cartella app di Dropbox.", + "settings_mediaStorage_connect_gdrive_hint": "Accede con Google. I media sono archiviati nello spazio Drive privato di questa app.", + "settings_mediaStorage_connect_icloud_hint": "I media sono archiviati nel contenitore iCloud di questa app e si sincronizzano tramite il tuo ID Apple.", + "settings_mediaStorage_connect_action": "Collega {provider}" } diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index f714b0413..cd26fe75c 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -30841,6 +30841,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Upload photos on cellular'** String get settings_mediaStorage_policy_photosOnCellular; + + /// No description provided for @settings_mediaStorage_provider_label. + /// + /// In en, this message translates to: + /// **'Provider'** + String get settings_mediaStorage_provider_label; + + /// No description provided for @settings_mediaStorage_connect_dropbox_hint. + /// + /// In en, this message translates to: + /// **'Uses your Dropbox connection from Cloud Sync. Media is stored in your Dropbox app folder.'** + String get settings_mediaStorage_connect_dropbox_hint; + + /// No description provided for @settings_mediaStorage_connect_gdrive_hint. + /// + /// In en, this message translates to: + /// **'Signs in with Google. Media is stored in this app\'s private Drive space.'** + String get settings_mediaStorage_connect_gdrive_hint; + + /// No description provided for @settings_mediaStorage_connect_icloud_hint. + /// + /// In en, this message translates to: + /// **'Media is stored in this app\'s iCloud container and syncs through your Apple ID.'** + String get settings_mediaStorage_connect_icloud_hint; + + /// No description provided for @settings_mediaStorage_connect_action. + /// + /// In en, this message translates to: + /// **'Connect {provider}'** + String settings_mediaStorage_connect_action(String provider); } class _AppLocalizationsDelegate diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index bebba8013..fd832156e 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -18108,4 +18108,24 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'رفع الصور عبر شبكة الجوال'; + + @override + String get settings_mediaStorage_provider_label => 'المزود'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'يستخدم اتصال Dropbox من مزامنة السحابة. تُخزن الوسائط في مجلد التطبيق في Dropbox.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'يسجل الدخول عبر Google. تُخزن الوسائط في مساحة Drive الخاصة بهذا التطبيق.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'تُخزن الوسائط في حاوية iCloud لهذا التطبيق وتتزامن عبر Apple ID الخاص بك.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'توصيل $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 845de5eac..92f7a16dc 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -18415,4 +18415,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Fotos über Mobilfunk hochladen'; + + @override + String get settings_mediaStorage_provider_label => 'Anbieter'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Verwendet Ihre Dropbox-Verbindung aus der Cloud-Synchronisierung. Medien werden im Dropbox-App-Ordner gespeichert.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Meldet sich mit Google an. Medien werden im privaten Drive-Bereich dieser App gespeichert.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'Medien werden im iCloud-Container dieser App gespeichert und über Ihre Apple-ID synchronisiert.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return '$provider verbinden'; + } } diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 90adf5079..2ee5e9c10 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -18140,4 +18140,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Upload photos on cellular'; + + @override + String get settings_mediaStorage_provider_label => 'Provider'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Uses your Dropbox connection from Cloud Sync. Media is stored in your Dropbox app folder.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Signs in with Google. Media is stored in this app\'s private Drive space.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'Media is stored in this app\'s iCloud container and syncs through your Apple ID.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'Connect $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 479204291..6defab577 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -18457,4 +18457,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Subir fotos con datos móviles'; + + @override + String get settings_mediaStorage_provider_label => 'Proveedor'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Usa tu conexión de Dropbox de la sincronización en la nube. Los medios se guardan en la carpeta de la app en Dropbox.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Inicia sesión con Google. Los medios se guardan en el espacio privado de Drive de esta app.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'Los medios se guardan en el contenedor de iCloud de esta app y se sincronizan con tu Apple ID.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'Conectar $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 948f88362..153c1a3c0 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -18513,4 +18513,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Envoyer les photos en cellulaire'; + + @override + String get settings_mediaStorage_provider_label => 'Fournisseur'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Utilise votre connexion Dropbox de la synchronisation cloud. Les médias sont stockés dans le dossier d\'application Dropbox.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Connexion avec Google. Les médias sont stockés dans l\'espace Drive privé de cette application.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'Les médias sont stockés dans le conteneur iCloud de cette application et se synchronisent via votre identifiant Apple.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'Connecter $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 7b784ccaa..e0ea58f1d 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -17978,4 +17978,24 @@ class AppLocalizationsHe extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'העלה תמונות ברשת סלולרית'; + + @override + String get settings_mediaStorage_provider_label => 'ספק'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'משתמש בחיבור Dropbox מסנכרון הענן. המדיה נשמרת בתיקיית האפליקציה ב-Dropbox.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'מתחבר עם Google. המדיה נשמרת בשטח ה-Drive הפרטי של האפליקציה.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'המדיה נשמרת במיכל ה-iCloud של האפליקציה ומסתנכרנת דרך ה-Apple ID שלך.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'חבר את $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index e43321fe0..254ce55e8 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -18398,4 +18398,24 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Fotók feltöltése mobilhálózaton'; + + @override + String get settings_mediaStorage_provider_label => 'Szolgáltató'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'A felhőszinkronizálás Dropbox-kapcsolatát használja. A média a Dropbox alkalmazásmappájában tárolódik.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Google-fiókkal jelentkezik be. A média az alkalmazás privát Drive-területén tárolódik.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'A média az alkalmazás iCloud-tárolójában tárolódik, és az Apple ID-n keresztül szinkronizálódik.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return '$provider csatlakoztatása'; + } } diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 2f1fd7236..5aebdab56 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -18448,4 +18448,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Carica foto su rete mobile'; + + @override + String get settings_mediaStorage_provider_label => 'Provider'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Usa la tua connessione Dropbox della sincronizzazione cloud. I media sono archiviati nella cartella app di Dropbox.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Accede con Google. I media sono archiviati nello spazio Drive privato di questa app.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'I media sono archiviati nel contenitore iCloud di questa app e si sincronizzano tramite il tuo ID Apple.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'Collega $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index 9a66e51d8..2c3eca7bc 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -18301,4 +18301,24 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Foto\'s uploaden via mobiel'; + + @override + String get settings_mediaStorage_provider_label => 'Provider'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Gebruikt je Dropbox-koppeling uit Cloud Sync. Media wordt opgeslagen in je Dropbox-appmap.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Meldt aan met Google. Media wordt opgeslagen in de privé-Drive-ruimte van deze app.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'Media wordt opgeslagen in de iCloud-container van deze app en synchroniseert via je Apple ID.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return '$provider verbinden'; + } } diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 460b36711..4e0aa1619 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -18455,4 +18455,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => 'Enviar fotos pela rede móvel'; + + @override + String get settings_mediaStorage_provider_label => 'Provedor'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + 'Usa sua conexão do Dropbox da sincronização na nuvem. As mídias ficam na pasta do app no Dropbox.'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + 'Entra com o Google. As mídias ficam no espaço privado do Drive deste app.'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + 'As mídias ficam no contêiner do iCloud deste app e sincronizam pelo seu ID Apple.'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return 'Conectar $provider'; + } } diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 108c73ade..d5b313ccd 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -17527,4 +17527,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_mediaStorage_policy_photosOnCellular => '使用蜂窝数据上传照片'; + + @override + String get settings_mediaStorage_provider_label => '服务商'; + + @override + String get settings_mediaStorage_connect_dropbox_hint => + '使用云同步中的 Dropbox 连接。媒体存储在您的 Dropbox 应用文件夹中。'; + + @override + String get settings_mediaStorage_connect_gdrive_hint => + '使用 Google 登录。媒体存储在此应用的私有云端硬盘空间中。'; + + @override + String get settings_mediaStorage_connect_icloud_hint => + '媒体存储在此应用的 iCloud 容器中,并通过您的 Apple ID 同步。'; + + @override + String settings_mediaStorage_connect_action(String provider) { + return '连接 $provider'; + } } diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index b54dd3468..d796a8a94 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Bestaande bibliotheek uploaden", "settings_mediaStorage_backfill_enqueued": "{count} uploads in wachtrij", "settings_mediaStorage_policy_autoUpload": "Foto's automatisch uploaden", - "settings_mediaStorage_policy_photosOnCellular": "Foto's uploaden via mobiel" + "settings_mediaStorage_policy_photosOnCellular": "Foto's uploaden via mobiel", + "settings_mediaStorage_provider_label": "Provider", + "settings_mediaStorage_connect_dropbox_hint": "Gebruikt je Dropbox-koppeling uit Cloud Sync. Media wordt opgeslagen in je Dropbox-appmap.", + "settings_mediaStorage_connect_gdrive_hint": "Meldt aan met Google. Media wordt opgeslagen in de privé-Drive-ruimte van deze app.", + "settings_mediaStorage_connect_icloud_hint": "Media wordt opgeslagen in de iCloud-container van deze app en synchroniseert via je Apple ID.", + "settings_mediaStorage_connect_action": "{provider} verbinden" } diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index a31106861..56aa1a036 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "Enviar biblioteca existente", "settings_mediaStorage_backfill_enqueued": "{count} envios na fila", "settings_mediaStorage_policy_autoUpload": "Enviar fotos automaticamente", - "settings_mediaStorage_policy_photosOnCellular": "Enviar fotos pela rede móvel" + "settings_mediaStorage_policy_photosOnCellular": "Enviar fotos pela rede móvel", + "settings_mediaStorage_provider_label": "Provedor", + "settings_mediaStorage_connect_dropbox_hint": "Usa sua conexão do Dropbox da sincronização na nuvem. As mídias ficam na pasta do app no Dropbox.", + "settings_mediaStorage_connect_gdrive_hint": "Entra com o Google. As mídias ficam no espaço privado do Drive deste app.", + "settings_mediaStorage_connect_icloud_hint": "As mídias ficam no contêiner do iCloud deste app e sincronizam pelo seu ID Apple.", + "settings_mediaStorage_connect_action": "Conectar {provider}" } diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index cd122b741..6450eb541 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -5196,5 +5196,10 @@ "settings_mediaStorage_backfill_action": "上传现有媒体库", "settings_mediaStorage_backfill_enqueued": "已排队 {count} 个上传", "settings_mediaStorage_policy_autoUpload": "自动上传照片", - "settings_mediaStorage_policy_photosOnCellular": "使用蜂窝数据上传照片" + "settings_mediaStorage_policy_photosOnCellular": "使用蜂窝数据上传照片", + "settings_mediaStorage_provider_label": "服务商", + "settings_mediaStorage_connect_dropbox_hint": "使用云同步中的 Dropbox 连接。媒体存储在您的 Dropbox 应用文件夹中。", + "settings_mediaStorage_connect_gdrive_hint": "使用 Google 登录。媒体存储在此应用的私有云端硬盘空间中。", + "settings_mediaStorage_connect_icloud_hint": "媒体存储在此应用的 iCloud 容器中,并通过您的 Apple ID 同步。", + "settings_mediaStorage_connect_action": "连接 {provider}" } diff --git a/test/features/media_store/media_storage_page_test.dart b/test/features/media_store/media_storage_page_test.dart index 71b295e93..b51e3d82f 100644 --- a/test/features/media_store/media_storage_page_test.dart +++ b/test/features/media_store/media_storage_page_test.dart @@ -9,6 +9,7 @@ import 'package:submersion/features/media_store/data/media_store_service.dart'; import 'package:submersion/features/media_store/data/media_stores_repository.dart'; import 'package:submersion/features/media_store/presentation/pages/media_storage_page.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; import '../../helpers/in_memory_media_object_store.dart'; @@ -25,14 +26,37 @@ class _RecordingService extends MediaStoreService { int connectCalls = 0; int testCalls = 0; + int dropboxCalls = 0; + int gdriveCalls = 0; + int icloudCalls = 0; + + static const _result = MediaStoreConnectResult( + storeId: 'store-x', + createdNewStore: true, + ); @override Future connectS3(S3Config config) async { connectCalls++; - return const MediaStoreConnectResult( - storeId: 'store-x', - createdNewStore: true, - ); + return _result; + } + + @override + Future connectDropbox() async { + dropboxCalls++; + return _result; + } + + @override + Future connectGoogleDrive() async { + gdriveCalls++; + return _result; + } + + @override + Future connectICloud() async { + icloudCalls++; + return _result; } @override @@ -49,13 +73,14 @@ void main() { service = _RecordingService(); }); - Widget app() => ProviderScope( + Widget app({bool apple = true}) => ProviderScope( overrides: [ mediaStoreRuntimeProvider.overrideWith((ref) async => null), mediaStoreCredentialsStoreProvider.overrideWithValue( MediaStoreCredentialsStore(storage: InMemoryKeychain()), ), mediaStoreServiceProvider.overrideWithValue(service), + isApplePlatformProvider.overrideWithValue(apple), ], child: const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -97,6 +122,9 @@ void main() { }); testWidgets('valid form calls connectS3 once', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); await tester.runAsync(() async { await tester.pumpWidget(app()); await Future.delayed(const Duration(milliseconds: 50)); @@ -115,6 +143,7 @@ void main() { await tester.enterText(find.byKey(const Key('media-s3-secret-key')), 'SK'); await tester.ensureVisible(find.byKey(const Key('media-s3-connect'))); + await tester.pump(); await tester.runAsync(() async { await tester.tap(find.byKey(const Key('media-s3-connect'))); await Future.delayed(const Duration(milliseconds: 50)); @@ -123,4 +152,55 @@ void main() { expect(service.connectCalls, 1); }); + + testWidgets('chooser defaults to S3 with the form visible', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(app()); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + + expect(find.byKey(const Key('media-provider-chooser')), findsOneWidget); + expect(find.byKey(const Key('media-s3-endpoint')), findsOneWidget); + expect(find.text('iCloud'), findsOneWidget); + expect(find.byKey(const Key('media-dropbox-connect')), findsNothing); + }); + + testWidgets('selecting dropbox swaps the form for the connect panel ' + 'and calls the service', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(app()); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + + await tester.tap(find.text('Dropbox')); + await tester.pump(); + + expect(find.byKey(const Key('media-s3-endpoint')), findsNothing); + expect(find.byKey(const Key('media-dropbox-connect')), findsOneWidget); + + await tester.ensureVisible(find.byKey(const Key('media-dropbox-connect'))); + await tester.runAsync(() async { + await tester.tap(find.byKey(const Key('media-dropbox-connect'))); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + + expect(service.dropboxCalls, 1); + expect(service.connectCalls, 0); + }); + + testWidgets('the iCloud segment is absent on non-Apple ' + 'platforms', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(app(apple: false)); + await Future.delayed(const Duration(milliseconds: 50)); + await tester.pump(); + }); + + expect(find.byKey(const Key('media-provider-chooser')), findsOneWidget); + expect(find.text('iCloud'), findsNothing); + expect(find.text('Google Drive'), findsOneWidget); + }); } From e673f577b4ce3bc5b6578052c3c4b4afdcfb21b4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:59:30 -0400 Subject: [PATCH 17/18] feat(media-store): phase 4 exit coverage --- .../media_store_end_to_end_test.dart | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/test/features/media_store/media_store_end_to_end_test.dart b/test/features/media_store/media_store_end_to_end_test.dart index dd37c543a..3301cee18 100644 --- a/test/features/media_store/media_store_end_to_end_test.dart +++ b/test/features/media_store/media_store_end_to_end_test.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/core/services/cloud_storage/dropbox/dropbox_api_client.dart'; +import 'package:submersion/core/services/media_store/dropbox_media_object_store.dart'; import 'package:submersion/features/media/data/repositories/media_repository.dart'; import 'package:submersion/features/media/data/resolvers/media_store_resolver.dart'; import 'package:submersion/features/media/data/services/media_source_resolver_registry.dart'; @@ -16,6 +18,7 @@ import 'package:submersion/features/media_store/data/media_store_worker.dart'; import 'package:submersion/features/media_store/data/media_transfer_queue_repository.dart'; import 'package:submersion/features/media_store/data/media_upload_pipeline.dart'; +import '../../helpers/fake_dropbox_server.dart'; import '../../helpers/in_memory_media_object_store.dart'; import '../../helpers/test_database.dart'; import '../media_store/support/fake_local_file_resolver.dart'; @@ -350,4 +353,84 @@ void main() { expect(thumbBytes, posterBytes, reason: 'poster round-trips exactly'); expect(thumbBytes, isNot(equals(videoBytes))); }); + + test('the cross-device video flow works over the Dropbox adapter', () async { + // Phase 4 exit criterion: the same video journey as the in-memory + // test, but through the real Dropbox protocol adapter (upload + // sessions on the way up, ranged downloads on the way back). Both + // devices talk to one fake Dropbox account. + final server = FakeDropboxServer(); + DropboxMediaObjectStore storeFor() => DropboxMediaObjectStore( + client: DropboxApiClient( + getAccessToken: () async => server.bearerToken, + onAccessTokenRejected: () {}, + httpClient: server.client, + ), + // Well below the video size so the upload takes the session path. + chunkSizeBytes: 16 * 1024, + ); + + final mediaRepositoryA = MediaRepository(); + final cacheA = MediaCacheStore(database: cacheDbA, root: rootA); + final queueA = MediaTransferQueueRepository(database: cacheDbA); + final resolverA = FakeLocalFileResolver(); + final workerA = MediaStoreWorker( + queue: queueA, + pipeline: MediaUploadPipeline( + mediaRepository: mediaRepositoryA, + queue: queueA, + store: storeFor(), + registry: MediaSourceResolverRegistry({ + MediaSourceType.localFile: resolverA, + }), + cache: cacheA, + ), + ); + + final videoBytes = List.generate(64 * 1024, (i) => (i * 13) % 251); + final video = File('${rootA.path}/drift.mp4')..writeAsBytesSync(videoBytes); + resolverA.data = FileData(file: video); + final posterBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpe' + 'qz8AAAAASUVORK5CYII=', + ); + resolverA.thumbnailData = BytesData(bytes: posterBytes); + + final created = await mediaRepositoryA.createMedia( + domain.MediaItem( + id: '', + mediaType: domain.MediaType.video, + sourceType: MediaSourceType.localFile, + filePath: video.path, + localPath: video.path, + originalFilename: 'drift.mp4', + takenAt: DateTime(2026, 7, 1), + createdAt: DateTime(2026, 7, 1), + updatedAt: DateTime(2026, 7, 1), + ), + ); + await queueA.enqueueUpload(mediaId: created.id); + await workerA.drain(); + + final uploaded = (await mediaRepositoryA.getMediaById(created.id))!; + expect(uploaded.remoteUploadedAt, isNotNull); + expect(uploaded.remoteThumbUploadedAt, isNotNull); + expect(server.files, hasLength(2), reason: 'thumb + original'); + + // Device B holds only the synced row and its own Dropbox link. + final onB = uploaded.copyWith( + platformAssetId: null, + localPath: '/nonexistent/on/device-b.mp4', + ); + final cacheB = MediaCacheStore(database: cacheDbB, root: rootB); + final resolverB = MediaStoreResolver(store: storeFor(), cache: cacheB); + + final full = await resolverB.tryResolveRemote(onB, thumbnail: false); + expect(full, isA()); + expect(await (full! as FileData).file.readAsBytes(), videoBytes); + + final thumb = await resolverB.tryResolveRemote(onB, thumbnail: true); + expect(thumb, isA()); + expect(await (thumb! as FileData).file.readAsBytes(), posterBytes); + }); } From 2d9298375221122f6d5a7eef7ecbb7d25d207515 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 11 Jul 2026 00:35:25 -0400 Subject: [PATCH 18/18] fix(media-store): harden resume parsing, clear stale errors, forward content type --- .../cloud_storage/s3/s3_api_client.dart | 16 +++++- .../media_store/s3_media_object_store.dart | 9 ++-- .../data/media_transfer_queue_repository.dart | 8 +-- .../s3_storage_provider_test.dart | 6 ++- .../s3_media_object_store_test.dart | 52 +++++++++++++++++++ .../media_transfer_queue_repository_test.dart | 10 ++++ .../presentation/s3_config_page_test.dart | 6 ++- 7 files changed, 96 insertions(+), 11 deletions(-) diff --git a/lib/core/services/cloud_storage/s3/s3_api_client.dart b/lib/core/services/cloud_storage/s3/s3_api_client.dart index 0a30bec4b..cc0f9e253 100644 --- a/lib/core/services/cloud_storage/s3/s3_api_client.dart +++ b/lib/core/services/cloud_storage/s3/s3_api_client.dart @@ -62,8 +62,19 @@ class S3ApiClient { /// and is updated for the client's lifetime when the server corrects it. String _region; - Future putObject(String key, Uint8List bytes) async { - final response = await _sendWithRetry('PUT', key, body: bytes); + Future putObject( + String key, + Uint8List bytes, { + String? contentType, + }) async { + final response = await _sendWithRetry( + 'PUT', + key, + body: bytes, + extraHeaders: contentType == null + ? const {} + : {'content-type': contentType}, + ); if (response.statusCode != 200) _throwFor('upload', key, response); } @@ -180,6 +191,7 @@ class S3ApiClient { 'POST', key, queryParams: {'uploads': ''}, + extraHeaders: {'content-type': contentType}, ); if (response.statusCode != 200) _throwFor('start upload', key, response); final uploadId = _xmlElementText(response.body, 'UploadId'); diff --git a/lib/core/services/media_store/s3_media_object_store.dart b/lib/core/services/media_store/s3_media_object_store.dart index 02f40d781..541c02f4c 100644 --- a/lib/core/services/media_store/s3_media_object_store.dart +++ b/lib/core/services/media_store/s3_media_object_store.dart @@ -81,7 +81,7 @@ class S3MediaObjectStore implements MediaObjectStore { ); } try { - await _client.putObject(_wire(key), bytes); + await _client.putObject(_wire(key), bytes, contentType: contentType); onProgress?.call(bytes.length, bytes.length); } on CloudStorageException catch (e) { throw _map('put', key, e); @@ -199,9 +199,10 @@ class S3MediaObjectStore implements MediaObjectStore { return null; } return (uploadId: uploadId, parts: recorded); - } on Exception { - // Malformed JSON, unknown uploadId, or listParts failure: the resume - // point is unusable; start over. + } catch (_) { + // Malformed or wrongly-typed JSON (persisted resume state is + // untrusted; bad casts throw TypeError, an Error), unknown uploadId, + // or listParts failure: the resume point is unusable; start over. await _abortQuietly(wireKey, uploadId); return null; } diff --git a/lib/features/media_store/data/media_transfer_queue_repository.dart b/lib/features/media_store/data/media_transfer_queue_repository.dart index 323903093..caf088dee 100644 --- a/lib/features/media_store/data/media_transfer_queue_repository.dart +++ b/lib/features/media_store/data/media_transfer_queue_repository.dart @@ -65,9 +65,10 @@ class MediaTransferQueueRepository { Future markTransferring(int id) => _setState(id, 'transferring'); - /// Completion also clears resume/progress state: a finished transfer - /// must not leak a stale resume point into a future re-enqueue of the - /// same media. + /// Completion also clears resume/progress state and any error message + /// from earlier attempts: a finished transfer must not leak a stale + /// resume point into a future re-enqueue of the same media, and a done + /// row must not display a failure it recovered from. Future markDone(int id) async { await (_db.update( _db.mediaTransferQueue, @@ -77,6 +78,7 @@ class MediaTransferQueueRepository { resumeStateJson: const Value(null), progressBytes: const Value(null), totalBytes: const Value(null), + errorMessage: const Value(null), updatedAt: Value(DateTime.now().millisecondsSinceEpoch), ), ); diff --git a/test/core/services/cloud_storage/s3_storage_provider_test.dart b/test/core/services/cloud_storage/s3_storage_provider_test.dart index 92f00b585..40c10ba0b 100644 --- a/test/core/services/cloud_storage/s3_storage_provider_test.dart +++ b/test/core/services/cloud_storage/s3_storage_provider_test.dart @@ -41,7 +41,11 @@ class _FakeS3ApiClient extends Fake implements S3ApiClient { } @override - Future putObject(String key, Uint8List bytes) async { + Future putObject( + String key, + Uint8List bytes, { + String? contentType, + }) async { _assertOpen(); calls.add('put:$key'); objects[key] = bytes; diff --git a/test/core/services/media_store/s3_media_object_store_test.dart b/test/core/services/media_store/s3_media_object_store_test.dart index 8e8c052c2..ec9d5682b 100644 --- a/test/core/services/media_store/s3_media_object_store_test.dart +++ b/test/core/services/media_store/s3_media_object_store_test.dart @@ -176,6 +176,58 @@ void main() { ); }); + test('a corrupted resume state (wrong-typed fields) restarts fresh ' + 'instead of crashing', () async { + final store = build(partSizeBytes: 64 * 1024); + final bytes = List.generate(130 * 1024, (i) => i % 197); + final src = File('${tmp.path}/corrupt.mp4')..writeAsBytesSync(bytes); + + // partSizeBytes matches so validation reaches the parts cast; "n" + // holding a string throws TypeError, which is an Error, not an + // Exception. Persisted resume JSON is untrusted input and must never + // escape putFile. + await store.putFile( + 'smv1/objects/aa/corrupt.mp4', + src, + contentType: 'video/mp4', + resumeStateJson: + '{"uploadId":"upload-9","partSizeBytes":65536,' + '"parts":[{"n":"one","etag":7}]}', + ); + expect( + server.objects['submersion-media/smv1/objects/aa/corrupt.mp4'], + bytes, + ); + }); + + test('putFile forwards the content type on both upload paths', () async { + final store = build(partSizeBytes: 64 * 1024); + + final small = File('${tmp.path}/small.jpg')..writeAsBytesSync([1, 2, 3]); + await store.putFile( + 'smv1/objects/aa/small.jpg', + small, + contentType: 'image/jpeg', + ); + final singleShot = server.captured.lastWhere( + (r) => + r.method == 'PUT' && !r.url.queryParameters.containsKey('partNumber'), + ); + expect(singleShot.headers['content-type'], 'image/jpeg'); + + final big = File('${tmp.path}/big.mp4') + ..writeAsBytesSync(List.generate(130 * 1024, (i) => i % 251)); + await store.putFile( + 'smv1/objects/aa/big.mp4', + big, + contentType: 'video/mp4', + ); + final initiate = server.captured.lastWhere( + (r) => r.method == 'POST' && r.url.queryParameters.containsKey('uploads'), + ); + expect(initiate.headers['content-type'], 'video/mp4'); + }); + test('a stale resume state (unknown uploadId) aborts and restarts ' 'fresh', () async { final store = build(partSizeBytes: 64 * 1024); diff --git a/test/features/media_store/media_transfer_queue_repository_test.dart b/test/features/media_store/media_transfer_queue_repository_test.dart index 513ab1ad6..f7424a6fe 100644 --- a/test/features/media_store/media_transfer_queue_repository_test.dart +++ b/test/features/media_store/media_transfer_queue_repository_test.dart @@ -56,6 +56,16 @@ void main() { expect(await repo.nextPending(t0.add(const Duration(days: 1))), isNull); }); + test('markDone clears a stale error message from an earlier ' + 'failure', () async { + final id = await repo.enqueueUpload(mediaId: 'm1'); + await repo.markFailed(id, 'boom'); + await repo.markDone(id); + final row = (await repo.allForTesting()).single; + expect(row.state, 'done'); + expect(row.errorMessage, isNull); + }); + test('v2 migration creates both tables', () async { final cols = await db .customSelect("PRAGMA table_info('media_transfer_queue')") diff --git a/test/features/settings/presentation/s3_config_page_test.dart b/test/features/settings/presentation/s3_config_page_test.dart index 15926edf4..549f90413 100644 --- a/test/features/settings/presentation/s3_config_page_test.dart +++ b/test/features/settings/presentation/s3_config_page_test.dart @@ -50,7 +50,11 @@ class _FakeS3ApiClient extends Fake implements S3ApiClient { void Function(String region)? onRegionCorrected; @override - Future putObject(String key, Uint8List bytes) async { + Future putObject( + String key, + Uint8List bytes, { + String? contentType, + }) async { calls.add('put:$key'); _objects[key] = bytes; }