Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e329621
docs: media store phase 3 implementation plan
ericgriffin Jul 10, 2026
415bbd5
feat(media-store): progress and resume hooks on MediaObjectStore
ericgriffin Jul 10, 2026
cb3a91c
feat(media-store): queue resume-state and progress persistence
ericgriffin Jul 10, 2026
4c57d6c
feat(media-store): S3 multipart upload with resume and streamed download
ericgriffin Jul 10, 2026
18d70d9
feat(media-store): video uploads with resume and progress wiring
ericgriffin Jul 10, 2026
302baa2
feat(media-store): store-backed video playback path
ericgriffin Jul 10, 2026
2c8b365
feat(media-store): transfer progress bars
ericgriffin Jul 10, 2026
a401bab
feat(media-store): phase 3 exit coverage
ericgriffin Jul 10, 2026
f984f95
docs: media store phase 4 implementation plan
ericgriffin Jul 10, 2026
0c36b3c
feat(media-store): dropbox session primitives and range download
ericgriffin Jul 10, 2026
26d1112
feat(media-store): dropbox media adapter with session resume
ericgriffin Jul 10, 2026
3d1a83f
feat(media-store): google drive media adapter with resumable sessions
ericgriffin Jul 10, 2026
ed82e02
feat(media-store): icloud media adapter over the ubiquity container
ericgriffin Jul 10, 2026
bc224e5
feat(media-store): provider-typed attach state and runtime
ericgriffin Jul 10, 2026
085f37a
feat(media-store): per-provider connect flows
ericgriffin Jul 10, 2026
1fbacef
feat(media-store): provider chooser with dropbox, drive, icloud connect
ericgriffin Jul 10, 2026
e673f57
feat(media-store): phase 4 exit coverage
ericgriffin Jul 10, 2026
d570c02
Merge pull request #559 from submersion-app/worktree-media-store-phase4
ericgriffin Jul 11, 2026
2d92983
fix(media-store): harden resume parsing, clear stale errors, forward …
ericgriffin Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,225 changes: 1,225 additions & 0 deletions docs/superpowers/plans/2026-07-10-media-store-phase3.md

Large diffs are not rendered by default.

693 changes: 693 additions & 0 deletions docs/superpowers/plans/2026-07-10-media-store-phase4.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion lib/core/database/local_cache_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class MediaTransferQueue extends Table {
TextColumn get resumeStateJson => text().nullable()();
TextColumn get errorMessage => text().nullable()();
IntColumn get priority => integer().withDefault(const Constant(0))();
// Transfer progress (v3), surfaced in the Transfers view.
IntColumn get progressBytes => integer().nullable()();
IntColumn get totalBytes => integer().nullable()();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();
}
Expand All @@ -52,15 +55,20 @@ class LocalCacheDatabase extends _$LocalCacheDatabase {
LocalCacheDatabase(super.e);

@override
int get schemaVersion => 2;
int get schemaVersion => 3;

@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 2) {
// Creates the tables with the CURRENT schema, columns included.
await m.createTable(mediaTransferQueue);
await m.createTable(mediaCacheEntries);
}
if (from >= 2 && from < 3) {
await m.addColumn(mediaTransferQueue, mediaTransferQueue.progressBytes);
await m.addColumn(mediaTransferQueue, mediaTransferQueue.totalBytes);
}
},
);
}
125 changes: 91 additions & 34 deletions lib/core/services/cloud_storage/dropbox/dropbox_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<DropboxFileMetadata>> 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<List<DropboxFileMetadata>> listFolder({
String path = '',
bool recursive = false,
}) async {
final entries = <DropboxFileMetadata>[];
var response = await _send(
() => _rpcRequest('/2/files/list_folder', {
'path': path,
'recursive': false,
'recursive': recursive,
}),
);
while (true) {
Expand Down Expand Up @@ -162,21 +166,87 @@ 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<String> 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<void> 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<DropboxFileMetadata> 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<DropboxFileMetadata> _uploadChunked(
String path,
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
Expand All @@ -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<String, Object?>? body) {
Expand Down
10 changes: 10 additions & 0 deletions lib/core/services/cloud_storage/google_drive_storage_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<http.Client?> mediaHttpClient() async {
_allowSilentAuth = true;
if (await isAuthenticated()) return _authClient;
return null;
}

@override
Future<UploadResult> uploadFile(
Uint8List data,
Expand Down
Loading