import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; import 'package:aves/model/entry.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/service_policy.dart'; import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; abstract class ImageFileService { Future getEntry(String uri, String? mimeType); Future getSvg( String uri, String mimeType, { int? expectedContentLength, BytesReceivedCallback? onBytesReceived, }); Future getImage( String uri, String mimeType, int? rotationDegrees, bool isFlipped, { int? pageId, int? expectedContentLength, BytesReceivedCallback? onBytesReceived, }); // `rect`: region to decode, with coordinates in reference to `imageSize` Future getRegion( String uri, String mimeType, int rotationDegrees, bool isFlipped, int sampleSize, Rectangle regionRect, Size imageSize, { int? pageId, Object? taskKey, int? priority, }); Future getThumbnail({ required String uri, required String mimeType, required int rotationDegrees, required int? pageId, required bool isFlipped, required int? dateModifiedSecs, required double extent, Object? taskKey, int? priority, }); Future clearSizedThumbnailDiskCache(); bool cancelRegion(Object taskKey); bool cancelThumbnail(Object taskKey); Future? resumeLoading(Object taskKey); Stream delete(Iterable entries); Stream move( Iterable entries, { required bool copy, required String destinationAlbum, }); Stream export( Iterable entries, { required String mimeType, required String destinationAlbum, }); Future> captureFrame( AvesEntry entry, { required String desiredName, required Map exif, required Uint8List bytes, required String destinationAlbum, }); Future> rename(AvesEntry entry, String newName); Future> rotate(AvesEntry entry, {required bool clockwise}); Future> flip(AvesEntry entry); } class PlatformImageFileService implements ImageFileService { static const platform = MethodChannel('deckers.thibault/aves/image'); static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/image_byte_stream'); static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/image_op_stream'); static const double thumbnailDefaultSize = 64.0; static Map _toPlatformEntryMap(AvesEntry entry) { return { 'uri': entry.uri, 'path': entry.path, 'pageId': entry.pageId, 'mimeType': entry.mimeType, 'width': entry.width, 'height': entry.height, 'rotationDegrees': entry.rotationDegrees, 'isFlipped': entry.isFlipped, 'dateModifiedSecs': entry.dateModifiedSecs, 'sizeBytes': entry.sizeBytes, }; } @override Future getEntry(String uri, String? mimeType) async { try { final result = await platform.invokeMethod('getEntry', { 'uri': uri, 'mimeType': mimeType, }) as Map; return AvesEntry.fromMap(result); } on PlatformException catch (e) { await reportService.recordChannelError('getEntry', e); } return null; } @override Future getSvg( String uri, String mimeType, { int? expectedContentLength, BytesReceivedCallback? onBytesReceived, }) => getImage( uri, mimeType, 0, false, expectedContentLength: expectedContentLength, onBytesReceived: onBytesReceived, ); @override Future getImage( String uri, String mimeType, int? rotationDegrees, bool isFlipped, { int? pageId, int? expectedContentLength, BytesReceivedCallback? onBytesReceived, }) { try { final completer = Completer.sync(); final sink = OutputBuffer(); var bytesReceived = 0; _byteStreamChannel.receiveBroadcastStream({ 'uri': uri, 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, 'isFlipped': isFlipped, 'pageId': pageId, }).listen( (data) { final chunk = data as Uint8List; sink.add(chunk); if (onBytesReceived != null) { bytesReceived += chunk.length; try { onBytesReceived(bytesReceived, expectedContentLength); } catch (error, stack) { completer.completeError(error, stack); return; } } }, onError: completer.completeError, onDone: () { sink.close(); completer.complete(sink.bytes); }, cancelOnError: true, ); return completer.future; } on PlatformException catch (e) { reportService.recordChannelError('getImage', e); } return Future.sync(() => Uint8List(0)); } @override Future getRegion( String uri, String mimeType, int rotationDegrees, bool isFlipped, int sampleSize, Rectangle regionRect, Size imageSize, { int? pageId, Object? taskKey, int? priority, }) { return servicePolicy.call( () async { try { final result = await platform.invokeMethod('getRegion', { 'uri': uri, 'mimeType': mimeType, 'pageId': pageId, 'sampleSize': sampleSize, 'regionX': regionRect.left, 'regionY': regionRect.top, 'regionWidth': regionRect.width, 'regionHeight': regionRect.height, 'imageWidth': imageSize.width.toInt(), 'imageHeight': imageSize.height.toInt(), }); if (result != null) return result as Uint8List; } on PlatformException catch (e) { await reportService.recordChannelError('getRegion', e); } return Uint8List(0); }, priority: priority ?? ServiceCallPriority.getRegion, key: taskKey, ); } @override Future getThumbnail({ required String uri, required String mimeType, required int rotationDegrees, required int? pageId, required bool isFlipped, required int? dateModifiedSecs, required double extent, Object? taskKey, int? priority, }) { return servicePolicy.call( () async { try { final result = await platform.invokeMethod('getThumbnail', { 'uri': uri, 'mimeType': mimeType, 'dateModifiedSecs': dateModifiedSecs, 'rotationDegrees': rotationDegrees, 'isFlipped': isFlipped, 'widthDip': extent, 'heightDip': extent, 'pageId': pageId, 'defaultSizeDip': thumbnailDefaultSize, }); if (result != null) return result as Uint8List; } on PlatformException catch (e) { await reportService.recordChannelError('getThumbnail', e); } return Uint8List(0); }, priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail), key: taskKey, ); } @override Future clearSizedThumbnailDiskCache() async { try { return platform.invokeMethod('clearSizedThumbnailDiskCache'); } on PlatformException catch (e) { await reportService.recordChannelError('clearSizedThumbnailDiskCache', e); } } @override bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]); @override bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); @override Future? resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); @override Stream delete(Iterable entries) { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'delete', 'entries': entries.map(_toPlatformEntryMap).toList(), }).map((event) => ImageOpEvent.fromMap(event)); } on PlatformException catch (e) { reportService.recordChannelError('delete', e); return Stream.error(e); } } @override Stream move( Iterable entries, { required bool copy, required String destinationAlbum, }) { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'move', 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, 'destinationPath': destinationAlbum, }).map((event) => MoveOpEvent.fromMap(event)); } on PlatformException catch (e) { reportService.recordChannelError('move', e); return Stream.error(e); } } @override Stream export( Iterable entries, { required String mimeType, required String destinationAlbum, }) { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'export', 'entries': entries.map(_toPlatformEntryMap).toList(), 'mimeType': mimeType, 'destinationPath': destinationAlbum, }).map((event) => ExportOpEvent.fromMap(event)); } on PlatformException catch (e) { reportService.recordChannelError('export', e); return Stream.error(e); } } @override Future> captureFrame( AvesEntry entry, { required String desiredName, required Map exif, required Uint8List bytes, required String destinationAlbum, }) async { try { final result = await platform.invokeMethod('captureFrame', { 'uri': entry.uri, 'desiredName': desiredName, 'exif': exif, 'bytes': bytes, 'destinationPath': destinationAlbum, }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { await reportService.recordChannelError('captureFrame', e); } return {}; } @override Future> rename(AvesEntry entry, String newName) async { try { // returns map with: 'contentId' 'path' 'title' 'uri' (all optional) final result = await platform.invokeMethod('rename', { 'entry': _toPlatformEntryMap(entry), 'newName': newName, }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { await reportService.recordChannelError('rename', e); } return {}; } @override Future> rotate(AvesEntry entry, {required bool clockwise}) async { try { // returns map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('rotate', { 'entry': _toPlatformEntryMap(entry), 'clockwise': clockwise, }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { await reportService.recordChannelError('rotate', e); } return {}; } @override Future> flip(AvesEntry entry) async { try { // returns map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('flip', { 'entry': _toPlatformEntryMap(entry), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { await reportService.recordChannelError('flip', e); } return {}; } }