Skip to content

Attachment package refactor #311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.powersync.powersync_flutter_demo

import io.flutter.embedding.android.FlutterActivity

class MainActivity : FlutterActivity()
43 changes: 21 additions & 22 deletions demos/supabase-todolist/lib/attachments/photo_capture_widget.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import 'dart:async';

import 'dart:io';
import 'dart:typed_data';
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;
final CameraDescription camera;

const TakePhotoWidget(
{super.key, required this.todoId, required this.camera});
const TakePhotoWidget({super.key, required this.todoId, required this.camera});

@override
State<StatefulWidget> createState() {
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();

TodoItem.addPhoto(photoId, widget.todoId);
attachmentQueue.saveFile(photoId, photoSize);

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

final photoDataStream = photoFile.openRead().cast<Uint8List>();

// Save the photo attachment directly with the data stream
final attachment = await savePhotoAttachment(photoDataStream, 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
2 changes: 1 addition & 1 deletion demos/supabase-todolist/lib/attachments/photo_widget.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_attachments_stream/powersync_attachments_stream.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';
Expand Down
132 changes: 52 additions & 80 deletions demos/supabase-todolist/lib/attachments/queue.dart
Original file line number Diff line number Diff line change
@@ -1,90 +1,62 @@
import 'dart:async';

import 'dart:io';
import 'dart:typed_data';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.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_attachments_stream/powersync_attachments_stream.dart';
import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart';

import 'package:powersync_flutter_demo/models/schema.dart';

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

/// 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;
final log = Logger('AttachmentQueue');

Future<void> initializeAttachmentQueue(PowerSyncDatabase db) async {
// Use the app's document directory for local storage
final Directory appDocDir = await getApplicationDocumentsDirectory();

attachmentQueue = AttachmentQueue(
db: db,
remoteStorage: remoteStorage,
logger: log,
attachmentsDirectory: '${appDocDir.path}/attachments',
watchAttachments: () => db.watch('''
SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL
''').map((results) => results
.map((row) => WatchedAttachmentItem(
id: row['id'] as String,
fileExtension: 'jpg',
))
.toList()),
);

await attachmentQueue.startSync();
}

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';

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);
}

@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<Uint8List> 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
},
);
}
74 changes: 60 additions & 14 deletions demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart
Original file line number Diff line number Diff line change
@@ -1,49 +1,95 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_attachments_stream/powersync_attachments_stream.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 AbstractRemoteStorageAdapter {
static final _log = Logger('SupabaseStorageAdapter');

@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
2 changes: 1 addition & 1 deletion demos/supabase-todolist/lib/models/schema.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:powersync/powersync.dart';
import 'package:powersync_attachments_helper/powersync_attachments_helper.dart';
import 'package:powersync_attachments_stream/powersync_attachments_stream.dart';

const todosTable = 'todos';

Expand Down
8 changes: 7 additions & 1 deletion demos/supabase-todolist/lib/widgets/todo_item_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ class TodoItemWidget extends StatelessWidget {

Future<void> deleteTodo(TodoItem todo) async {
if (todo.photoId != null) {
attachmentQueue.deleteFile(todo.photoId!);

await attachmentQueue.deleteFile(
attachmentId: todo.photoId!,
updateHook: (context, attachment) async {
// await context.execute("UPDATE todos SET photo_id = NULL WHERE id = ?", [todo.id]);
},
);
}
await todo.delete();
}
Expand Down
Loading
Loading