From f984f951da988c29a46401435f5985da700028e8 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 10 Jul 2026 19:00:48 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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); + }); }