Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
37f9017
initial boilerplate of new attachments package
dean-journeyapps Jul 17, 2025
dc9952c
Update dependencies and enhance LocalStorage interface with metadata …
dean-journeyapps Jul 22, 2025
8846e72
init attachments package stream and demo with implementation
dean-journeyapps Jul 29, 2025
d1fc46d
Merge branch 'attachment-package-refactor' of github.com:powersync-ja…
dean-journeyapps Aug 5, 2025
8848f70
Refactor attachment handling and logging in the powersync_attachments…
dean-journeyapps Aug 11, 2025
c4a8a77
Added comprehensive comments and descriptions for key classes and met…
dean-journeyapps Aug 11, 2025
d76bcc2
Removed supabase-todolist-new-attachment demo and added the new atatc…
dean-journeyapps Aug 11, 2025
e747c76
Refactor attachment queue initialization and implemented default base…
dean-journeyapps Aug 11, 2025
be8d4af
Removed redundant logger names.
dean-journeyapps Aug 11, 2025
1d2c32e
Renamed SyncErrorHandler to AbstractSyncErrorHandler. Updated related…
dean-journeyapps Aug 11, 2025
4d08952
Refactor local storage implementation in powersync_attachments_stream…
dean-journeyapps Aug 12, 2025
934eb9c
Refactor logger variable name in attachment queue initialization for …
dean-journeyapps Aug 12, 2025
320fab4
Removed unecessary comment
dean-journeyapps Aug 12, 2025
525700c
Added attachment queue service as an export in common.dart
dean-journeyapps Aug 13, 2025
9a69c89
Refactor photo attachment handling in the Supabase Todo List demo. Up…
dean-journeyapps Aug 13, 2025
5ca3917
Updated documentation in SyncingService to streamline error handling …
dean-journeyapps Aug 13, 2025
9ec641e
Remoed dynamic variables, implemented context.deleteAttachment() and …
dean-journeyapps Aug 14, 2025
f788773
Formatting fixes
dean-journeyapps Aug 14, 2025
bd26d9e
Moved attachments stream implementation to powersync_core and marked …
dean-journeyapps Aug 18, 2025
248d748
Reduce public interface
simolus3 Aug 19, 2025
a3ea19d
Rename library
simolus3 Aug 19, 2025
d7daefc
Fix local storage tests
simolus3 Aug 19, 2025
57a4214
Unit tests for attachments
simolus3 Aug 20, 2025
aceecbf
Update demo
simolus3 Aug 21, 2025
c0e5584
Remove unused mime type in readFile
simolus3 Aug 21, 2025
7ea7b91
Restore web support
simolus3 Aug 21, 2025
e17cdb3
Merge remote-tracking branch 'origin/main' into attachment-package-re…
simolus3 Sep 2, 2025
884f6f7
Remove test from demo
simolus3 Sep 2, 2025
bf04f1d
Remove unused activity class
simolus3 Sep 2, 2025
1ce3e4b
Also listen for connection status
simolus3 Sep 3, 2025
84e8d7b
Merge remote-tracking branch 'origin/main' into attachment-package-re…
simolus3 Sep 29, 2025
1b66f0a
FIx clearing local uri
simolus3 Sep 29, 2025
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 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pubspec_overrides.yaml
.flutter-plugins-dependencies
.flutter-plugins
build
**/doc/api
.build

# Shared assets
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:path_provider/path_provider.dart';
import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_core/attachments/io.dart';

Future<LocalStorage> localAttachmentStorage() async {
final appDocDir = await getApplicationDocumentsDirectory();
return IOLocalStorage(appDocDir);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:powersync_core/attachments/attachments.dart';

Future<LocalStorage> localAttachmentStorage() async {
// This file is imported on the web, where we don't currently have a
// persistent local storage implementation.
return LocalStorage.inMemory();
}
35 changes: 17 additions & 18 deletions demos/supabase-todolist/lib/attachments/photo_capture_widget.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import 'dart:async';

import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:powersync/powersync.dart' as powersync;
import 'package:logging/logging.dart';
import 'package:powersync_flutter_demo/attachments/queue.dart';
import 'package:powersync_flutter_demo/models/todo_item.dart';
import 'package:powersync_flutter_demo/powersync.dart';

class TakePhotoWidget extends StatefulWidget {
final String todoId;
Expand All @@ -23,6 +21,7 @@ class TakePhotoWidget extends StatefulWidget {
class _TakePhotoWidgetState extends State<TakePhotoWidget> {
late CameraController _cameraController;
late Future<void> _initializeControllerFuture;
final log = Logger('TakePhotoWidget');

@override
void initState() {
Expand All @@ -37,33 +36,33 @@ class _TakePhotoWidgetState extends State<TakePhotoWidget> {
}

@override
// Dispose of the camera controller when the widget is disposed
void dispose() {
_cameraController.dispose();
super.dispose();
}

Future<void> _takePhoto(context) async {
try {
// Ensure the camera is initialized before taking a photo
log.info('Taking photo for todo: ${widget.todoId}');
await _initializeControllerFuture;

final XFile photo = await _cameraController.takePicture();
// copy photo to new directory with ID as name
String photoId = powersync.uuid.v4();
String storageDirectory = await attachmentQueue.getStorageDirectory();
await attachmentQueue.localStorage
.copyFile(photo.path, '$storageDirectory/$photoId.jpg');

int photoSize = await photo.length();
// Read the photo data as bytes
final photoFile = File(photo.path);
if (!await photoFile.exists()) {
log.warning('Photo file does not exist: ${photo.path}');
return;
}

final photoData = photoFile.openRead();

TodoItem.addPhoto(photoId, widget.todoId);
attachmentQueue.saveFile(photoId, photoSize);
// Save the photo attachment with the byte data
final attachment = await savePhotoAttachment(photoData, widget.todoId);

log.info('Photo attachment saved with ID: ${attachment.id}');
} catch (e) {
log.info('Error taking photo: $e');
log.severe('Error taking photo: $e');
}

// After taking the photo, navigate back to the previous screen
Navigator.pop(context);
}

Expand Down
13 changes: 8 additions & 5 deletions demos/supabase-todolist/lib/attachments/photo_widget.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import 'dart:io';

import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:flutter/material.dart';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_flutter_demo/attachments/camera_helpers.dart';
import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart';
import 'package:powersync_flutter_demo/attachments/queue.dart';

import '../models/todo_item.dart';
import '../powersync.dart';

class PhotoWidget extends StatefulWidget {
final TodoItem todo;
Expand Down Expand Up @@ -37,11 +39,12 @@ class _PhotoWidgetState extends State<PhotoWidget> {
if (photoId == null) {
return _ResolvedPhotoState(photoPath: null, fileExists: false);
}
photoPath = await attachmentQueue.getLocalUri('$photoId.jpg');
final appDocDir = await getApplicationDocumentsDirectory();
photoPath = p.join(appDocDir.path, '$photoId.jpg');

bool fileExists = await File(photoPath).exists();

final row = await attachmentQueue.db
final row = await db
.getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]);

if (row != null) {
Expand Down Expand Up @@ -98,7 +101,7 @@ class _PhotoWidgetState extends State<PhotoWidget> {
String? filePath = data.photoPath;
bool fileIsDownloading = !data.fileExists;
bool fileArchived =
data.attachment?.state == AttachmentState.archived.index;
data.attachment?.state == AttachmentState.archived;

if (fileArchived) {
return Column(
Expand Down
128 changes: 51 additions & 77 deletions demos/supabase-todolist/lib/attachments/queue.dart
Original file line number Diff line number Diff line change
@@ -1,90 +1,64 @@
import 'dart:async';

import 'package:logging/logging.dart';
import 'package:powersync/powersync.dart';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_flutter_demo/app_config.dart';
import 'package:powersync_core/attachments/attachments.dart';

import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart';

import 'package:powersync_flutter_demo/models/schema.dart';
import 'local_storage_unsupported.dart'
if (dart.library.io) 'local_storage_native.dart';

/// Global reference to the queue
late final PhotoAttachmentQueue attachmentQueue;
late AttachmentQueue attachmentQueue;
final remoteStorage = SupabaseStorageAdapter();
final logger = Logger('AttachmentQueue');

/// Function to handle errors when downloading attachments
/// Return false if you want to archive the attachment
Future<bool> onDownloadError(Attachment attachment, Object exception) async {
if (exception.toString().contains('Object not found')) {
return false;
}
return true;
}

class PhotoAttachmentQueue extends AbstractAttachmentQueue {
PhotoAttachmentQueue(db, remoteStorage)
: super(
db: db,
remoteStorage: remoteStorage,
onDownloadError: onDownloadError);

@override
init() async {
if (AppConfig.supabaseStorageBucket.isEmpty) {
log.info(
'No Supabase bucket configured, skip setting up PhotoAttachmentQueue watches');
return;
}

await super.init();
}

@override
Future<Attachment> saveFile(String fileId, int size,
{mediaType = 'image/jpeg'}) async {
String filename = '$fileId.jpg';
Future<void> initializeAttachmentQueue(PowerSyncDatabase db) async {
attachmentQueue = AttachmentQueue(
db: db,
remoteStorage: remoteStorage,
logger: logger,
localStorage: await localAttachmentStorage(),
watchAttachments: () => db.watch('''
SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL
''').map(
(results) => [
for (final row in results)
WatchedAttachmentItem(
id: row['id'] as String,
fileExtension: 'jpg',
)
],
),
);

Attachment photoAttachment = Attachment(
id: fileId,
filename: filename,
state: AttachmentState.queuedUpload.index,
mediaType: mediaType,
localUri: getLocalFilePathSuffix(filename),
size: size,
);

return attachmentsService.saveAttachment(photoAttachment);
}

@override
Future<Attachment> deleteFile(String fileId) async {
String filename = '$fileId.jpg';

Attachment photoAttachment = Attachment(
id: fileId,
filename: filename,
state: AttachmentState.queuedDelete.index);

return attachmentsService.saveAttachment(photoAttachment);
}
await attachmentQueue.startSync();
}

@override
StreamSubscription<void> watchIds({String fileExtension = 'jpg'}) {
log.info('Watching photos in $todosTable...');
return db.watch('''
SELECT photo_id FROM $todosTable
WHERE photo_id IS NOT NULL
''').map((results) {
return results.map((row) => row['photo_id'] as String).toList();
}).listen((ids) async {
List<String> idsInQueue = await attachmentsService.getAttachmentIds();
List<String> relevantIds =
ids.where((element) => !idsInQueue.contains(element)).toList();
syncingService.processIds(relevantIds, fileExtension);
});
}
Future<Attachment> savePhotoAttachment(
Stream<List<int>> photoData, String todoId,
{String mediaType = 'image/jpeg'}) async {
// Save the file using the AttachmentQueue API
return await attachmentQueue.saveFile(
data: photoData,
mediaType: mediaType,
fileExtension: 'jpg',
metaData: 'Photo attachment for todo: $todoId',
updateHook: (context, attachment) async {
// Update the todo item to reference this attachment
await context.execute(
'UPDATE todos SET photo_id = ? WHERE id = ?',
[attachment.id, todoId],
);
},
);
}

initializeAttachmentQueue(PowerSyncDatabase db) async {
attachmentQueue = PhotoAttachmentQueue(db, remoteStorage);
await attachmentQueue.init();
Future<Attachment> deletePhotoAttachment(String fileId) async {
return await attachmentQueue.deleteFile(
attachmentId: fileId,
updateHook: (context, attachment) async {
// Optionally update relationships in the same transaction
},
);
}
77 changes: 62 additions & 15 deletions demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart
Original file line number Diff line number Diff line change
@@ -1,49 +1,96 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';

import 'package:powersync_core/attachments/attachments.dart';
import 'package:powersync_flutter_demo/app_config.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:image/image.dart' as img;
import 'package:logging/logging.dart';

class SupabaseStorageAdapter implements RemoteStorage {
static final _log = Logger('SupabaseStorageAdapter');

class SupabaseStorageAdapter implements AbstractRemoteStorageAdapter {
@override
Future<void> uploadFile(String filename, File file,
{String mediaType = 'text/plain'}) async {
Future<void> uploadFile(
Stream<List<int>> fileData, Attachment attachment) async {
_checkSupabaseBucketIsConfigured();

// Check if attachment size is specified (required for buffer allocation)
final byteSize = attachment.size;
if (byteSize == null) {
throw Exception('Cannot upload a file with no byte size specified');
}

_log.info('uploadFile: ${attachment.filename} (size: $byteSize bytes)');

// Collect all stream data into a single Uint8List buffer
final buffer = Uint8List(byteSize);
var position = 0;

await for (final chunk in fileData) {
if (position + chunk.length > byteSize) {
throw Exception('File data exceeds specified size');
}
buffer.setRange(position, position + chunk.length, chunk);
position += chunk.length;
}

if (position != byteSize) {
throw Exception(
'File data size ($position) does not match specified size ($byteSize)');
}

// Create a temporary file from the buffer for upload
final tempFile =
File('${Directory.systemTemp.path}/${attachment.filename}');
try {
await tempFile.writeAsBytes(buffer);

await Supabase.instance.client.storage
.from(AppConfig.supabaseStorageBucket)
.upload(filename, file,
fileOptions: FileOptions(contentType: mediaType));
.upload(attachment.filename, tempFile,
fileOptions: FileOptions(
contentType:
attachment.mediaType ?? 'application/octet-stream'));

_log.info('Successfully uploaded ${attachment.filename}');
} catch (error) {
_log.severe('Error uploading ${attachment.filename}', error);
throw Exception(error);
} finally {
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}

@override
Future<Uint8List> downloadFile(String filePath) async {
Future<Stream<List<int>>> downloadFile(Attachment attachment) async {
_checkSupabaseBucketIsConfigured();
try {
_log.info('downloadFile: ${attachment.filename}');

Uint8List fileBlob = await Supabase.instance.client.storage
.from(AppConfig.supabaseStorageBucket)
.download(filePath);
final image = img.decodeImage(fileBlob);
Uint8List blob = img.JpegEncoder().encode(image!);
return blob;
.download(attachment.filename);

_log.info(
'Successfully downloaded ${attachment.filename} (${fileBlob.length} bytes)');

// Return the raw file data as a stream
return Stream.value(fileBlob);
} catch (error) {
_log.severe('Error downloading ${attachment.filename}', error);
throw Exception(error);
}
}

@override
Future<void> deleteFile(String filename) async {
Future<void> deleteFile(Attachment attachment) async {
_checkSupabaseBucketIsConfigured();

try {
await Supabase.instance.client.storage
.from(AppConfig.supabaseStorageBucket)
.remove([filename]);
.remove([attachment.filename]);
} catch (error) {
throw Exception(error);
}
Expand Down
Loading