Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions app/lib/auth/auth_api_client.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:papyrus/auth/auth_models.dart';
import 'package:papyrus/auth/papyrus_api_config.dart';
import 'package:papyrus/media/media_models.dart';

class AuthApiException implements Exception {
final int statusCode;
Expand Down Expand Up @@ -151,6 +154,46 @@ class AuthApiClient {
await _postJson(config.endpoint('/sync/powersync-upload'), accessToken: accessToken, body: {'batch': batch});
}

Future<MediaStorageUsage> fetchMediaUsage(String accessToken) async {
final json = await _getJson(config.endpoint('/media/usage'), accessToken: accessToken);
return MediaStorageUsage.fromJson(json);
}

Future<MediaAsset> uploadMedia(String accessToken, MediaUploadPayload payload) async {
final request = http.MultipartRequest('POST', config.endpoint('/media'))
..headers.addAll(_authHeaders(accessToken))
..fields['book_id'] = payload.bookId
..fields['kind'] = payload.kind.apiValue
..files.add(
http.MultipartFile.fromBytes(
'file',
payload.bytes,
filename: payload.filename,
contentType: _mediaType(payload.contentType),
),
);

final response = await http.Response.fromStream(await _httpClient.send(request));
return MediaAsset.fromJson(_decodeResponse(response));
}

Future<Uint8List> downloadMedia(String accessToken, String assetId) async {
final response = await _httpClient.get(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken));
if (response.statusCode >= 200 && response.statusCode < 300) {
return response.bodyBytes;
}
_decodeResponse(response);
throw const AuthApiException(statusCode: 0, message: 'Media download failed');
}

Future<void> deleteMedia(String accessToken, String assetId) async {
final response = await _httpClient.delete(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken));
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
_decodeResponse(response);
}

Future<Map<String, dynamic>> _getJson(Uri uri, {String? accessToken}) async {
final response = await _httpClient.get(uri, headers: _headers(accessToken));

Expand Down Expand Up @@ -185,6 +228,16 @@ class AuthApiClient {
};
}

Map<String, String> _authHeaders(String accessToken) {
return {'Accept': 'application/json', 'Authorization': 'Bearer $accessToken'};
}

MediaType _mediaType(String contentType) {
final parts = contentType.split('/');
if (parts.length != 2) return MediaType('application', 'octet-stream');
return MediaType(parts[0], parts[1]);
}

Map<String, dynamic> _decodeResponse(http.Response response) {
final decoded = response.body.isEmpty ? <String, dynamic>{} : jsonDecode(response.body) as Map<String, dynamic>;

Expand Down
25 changes: 25 additions & 0 deletions app/lib/auth/auth_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:papyrus/auth/auth_api_client.dart';
import 'package:papyrus/auth/auth_models.dart';
import 'package:papyrus/media/media_models.dart';
import 'package:papyrus/auth/token_store.dart';
import 'package:papyrus/platform/web_redirect.dart';

Expand Down Expand Up @@ -194,6 +195,30 @@ class AuthRepository {
});
}

Future<MediaStorageUsage> fetchMediaUsage() {
return _withFreshAccessToken((accessToken) {
return apiClient.fetchMediaUsage(accessToken);
});
}

Future<MediaAsset> uploadMedia(MediaUploadPayload payload) {
return _withFreshAccessToken((accessToken) {
return apiClient.uploadMedia(accessToken, payload);
});
}

Future<Uint8List> downloadMedia(String assetId) {
return _withFreshAccessToken((accessToken) {
return apiClient.downloadMedia(accessToken, assetId);
});
}

Future<void> deleteMedia(String assetId) {
return _withFreshAccessToken((accessToken) {
return apiClient.deleteMedia(accessToken, assetId);
});
}

Future<void> clearTokens() {
return tokenStore.clear();
}
Expand Down
4 changes: 4 additions & 0 deletions app/lib/data/data_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class DataStore extends ChangeNotifier {
if (repository == null) {
throw StateError('Book repository is not initialized');
}
_books[book.id] = book;
notifyListeners();
unawaited(repository.upsert(book));
}

Expand All @@ -105,6 +107,8 @@ class DataStore extends ChangeNotifier {
if (repository == null) {
throw StateError('Book repository is not initialized');
}
_books[book.id] = book;
notifyListeners();
unawaited(repository.upsert(book));
}

Expand Down
53 changes: 51 additions & 2 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ import 'package:papyrus/auth/auth_repository.dart';
import 'package:papyrus/auth/papyrus_api_config.dart';
import 'package:papyrus/auth/token_store.dart';
import 'package:papyrus/data/data_store.dart';
import 'package:papyrus/media/media_cache_service.dart';
import 'package:papyrus/media/media_models.dart';
import 'package:papyrus/media/media_upload_queue.dart';
import 'package:papyrus/powersync/powersync_service.dart';
import 'package:papyrus/powersync/papyrus_powersync_connector.dart';
import 'package:papyrus/powersync/sync_state.dart';
import 'package:papyrus/providers/auth_provider.dart';
import 'package:papyrus/providers/library_provider.dart';
import 'package:papyrus/providers/preferences_provider.dart';
import 'package:papyrus/providers/sync_settings_provider.dart';
import 'package:papyrus/services/book_import_service_stub.dart'
if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart';
import 'package:papyrus/providers/sidebar_provider.dart';
import 'package:papyrus/themes/app_theme.dart';
import 'package:provider/provider.dart';
Expand Down Expand Up @@ -42,6 +47,8 @@ class _PapyrusState extends State<Papyrus> {
late final DataStore _dataStore;
late final AuthProvider _authProvider;
late final SyncSettingsProvider _syncSettingsProvider;
late final MediaUploadQueue _mediaUploadQueue;
late final BookImportService _bookImportService;
late final PapyrusPowerSyncService _powerSyncService;
late final PapyrusApiConfig _officialApiConfig;
late AuthRepository _authRepository;
Expand All @@ -59,10 +66,15 @@ class _PapyrusState extends State<Papyrus> {
_authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey);

_dataStore = DataStore();
_mediaUploadQueue = MediaUploadQueue(widget.prefs);
_bookImportService = BookImportService();
_authProvider = AuthProvider(widget.prefs, repository: _authRepository);
_powerSyncService = PapyrusPowerSyncService(
connectorFactory: () =>
PapyrusPowerSyncConnector(authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig),
connectorFactory: () => PapyrusPowerSyncConnector(
authRepository: _authRepository,
config: _syncSettingsProvider.activeApiConfig,
onUploadComplete: _processMediaUploads,
),
);
unawaited(_dataStore.attachBookRepository(_powerSyncService));
_appRouter = AppRouter(authProvider: _authProvider);
Expand All @@ -76,6 +88,7 @@ class _PapyrusState extends State<Papyrus> {
_authProvider.removeListener(_syncPowerSyncAuthState);
_syncSettingsProvider.removeListener(_handleSyncSettingsChanged);
unawaited(_disposeDataServices());
_bookImportService.dispose();
_authProvider.dispose();
_syncSettingsProvider.dispose();
super.dispose();
Expand All @@ -99,6 +112,8 @@ class _PapyrusState extends State<Papyrus> {
if (user != null && !_authProvider.isOfflineMode) {
final userId = user.userId;
unawaited(_powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey));
unawaited(_refreshMediaUsage());
unawaited(_processMediaUploads());
return;
}

Expand Down Expand Up @@ -128,20 +143,52 @@ class _PapyrusState extends State<Papyrus> {
await _powerSyncService.deactivate(clearAuthenticated: false);
_authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey);
await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode);
unawaited(_refreshMediaUsage());
} finally {
_switchingSyncProfile = false;
_syncPowerSyncAuthState();
}
}

Future<void> _refreshMediaUsage() async {
if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return;
try {
await _mediaUploadQueue.refreshUsage(_authRepository.fetchMediaUsage);
} catch (_) {
// Usage is informational; failed refresh must not block data sync.
}
}

Future<void> _processMediaUploads() async {
if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return;
await _mediaUploadQueue.processPending(
dataStore: _dataStore,
readBookFile: _bookImportService.getBookFile,
uploadMedia: (payload) async {
try {
return await _authRepository.uploadMedia(payload);
} on AuthApiException catch (error) {
if (error.statusCode == 409) {
throw const MediaUploadException.storageFull();
}
rethrow;
}
},
);
await _refreshMediaUsage();
}

@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Core data store - single source of truth
ChangeNotifierProvider.value(value: _dataStore),
ChangeNotifierProvider.value(value: _mediaUploadQueue),
ChangeNotifierProvider.value(value: _syncSettingsProvider),
Provider.value(value: _powerSyncService),
Provider.value(value: _bookImportService),
Provider(create: _createMediaCacheService),
StreamProvider<SyncState>.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState),
// Auth and UI state providers
ChangeNotifierProvider.value(value: _authProvider),
Expand All @@ -165,3 +212,5 @@ class _PapyrusState extends State<Papyrus> {
);
}
}

MediaCacheService _createMediaCacheService(BuildContext _) => const MediaCacheService();
57 changes: 57 additions & 0 deletions app/lib/media/media_cache_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:papyrus/models/book.dart';

typedef LocalBookFileReader = Future<Uint8List?> Function(String bookId);
typedef LocalBookFileWriter = Future<void> Function(String bookId, String extension, Uint8List bytes);
typedef MediaDownloader = Future<Uint8List> Function(String assetId);

/// Coordinates lazy download and platform-local caching for private media.
class MediaCacheService {
const MediaCacheService();

/// Returns a cached book file when present and, if the book has a stored
/// hash, the bytes match the expected hash.
Future<Uint8List?> getValidCachedBookFile(Book book, {required LocalBookFileReader readLocalBookFile}) async {
final cached = await readLocalBookFile(book.id);
if (cached == null) return null;
return _matchesExpectedHash(cached, book.fileHash) ? cached : null;
}

/// Returns local book bytes, downloading and caching private server media
/// when needed.
Future<Uint8List> ensureBookFileCached(
Book book, {
required LocalBookFileReader readLocalBookFile,
required LocalBookFileWriter writeLocalBookFile,
required MediaDownloader downloadMedia,
}) async {
final cached = await getValidCachedBookFile(book, readLocalBookFile: readLocalBookFile);
if (cached != null) return cached;

final mediaId = book.fileMediaId;
if (mediaId == null || mediaId.isEmpty) {
throw StateError('Book file is not available on this device or server.');
}

final downloaded = await downloadMedia(mediaId);
if (!_matchesExpectedHash(downloaded, book.fileHash)) {
throw StateError('Downloaded book file did not match the expected hash.');
}

await writeLocalBookFile(book.id, _extensionFor(book), downloaded);
return downloaded;
}

String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString();

bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) {
if (expectedHash == null || expectedHash.isEmpty) return true;
return sha256Hex(bytes) == expectedHash;
}

String _extensionFor(Book book) {
return book.fileFormat?.name ?? 'bin';
}
}
Loading
Loading