import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; class ImageFileService { static const platform = MethodChannel('deckers.thibault/aves/image'); static final StreamsChannel mediaStoreChannel = StreamsChannel('deckers.thibault/aves/mediastorestream'); static final StreamsChannel byteChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static const double thumbnailDefaultSize = 64.0; static Map _toPlatformEntryMap(ImageEntry entry) { return { 'uri': entry.uri, 'path': entry.path, 'mimeType': entry.mimeType, 'width': entry.width, 'height': entry.height, 'rotationDegrees': entry.rotationDegrees, 'isFlipped': entry.isFlipped, 'dateModifiedSecs': entry.dateModifiedSecs, }; } // knownEntries: map of contentId -> dateModifiedSecs static Stream getImageEntries(Map knownEntries) { try { return mediaStoreChannel.receiveBroadcastStream({ 'knownEntries': knownEntries, }).map((event) => ImageEntry.fromMap(event)); } on PlatformException catch (e) { debugPrint('getImageEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); return Stream.error(e); } } static Future> getObsoleteEntries(List knownContentIds) async { try { final result = await platform.invokeMethod('getObsoleteEntries', { 'knownContentIds': knownContentIds, }); return (result as List).cast(); } on PlatformException catch (e) { debugPrint('getObsoleteEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return []; } static Future getImageEntry(String uri, String mimeType) async { debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType'); try { final result = await platform.invokeMethod('getImageEntry', { 'uri': uri, 'mimeType': mimeType, }) as Map; return ImageEntry.fromMap(result); } on PlatformException catch (e) { debugPrint('getImageEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return null; } static Future getImage( String uri, String mimeType, int rotationDegrees, bool isFlipped, { int expectedContentLength, BytesReceivedCallback onBytesReceived, }) { try { final completer = Completer.sync(); final sink = _OutputBuffer(); var bytesReceived = 0; byteChannel.receiveBroadcastStream({ 'uri': uri, 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, 'isFlipped': isFlipped ?? false, }).listen( (data) { final chunk = data as Uint8List; sink.add(chunk); if (onBytesReceived != null) { bytesReceived += chunk.length; try { onBytesReceived(bytesReceived, expectedContentLength); } catch (error, stackTrace) { completer.completeError(error, stackTrace); return; } } }, onError: completer.completeError, onDone: () { sink.close(); completer.complete(sink.bytes); }, cancelOnError: true, ); return completer.future; } on PlatformException catch (e) { debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return Future.sync(() => null); } // `rect`: region to decode, with coordinates in reference to `imageSize` static Future getRegion( String uri, String mimeType, int rotationDegrees, bool isFlipped, int sampleSize, Rectangle regionRect, Size imageSize, { Object taskKey, int priority, }) { return servicePolicy.call( () async { try { final result = await platform.invokeMethod('getRegion', { 'uri': uri, 'mimeType': mimeType, 'sampleSize': sampleSize, 'regionX': regionRect.left, 'regionY': regionRect.top, 'regionWidth': regionRect.width, 'regionHeight': regionRect.height, 'imageWidth': imageSize.width.toInt(), 'imageHeight': imageSize.height.toInt(), }); return result as Uint8List; } on PlatformException catch (e) { debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return null; }, priority: priority ?? ServiceCallPriority.getRegion, key: taskKey, ); } static Future getThumbnail( String uri, String mimeType, int dateModifiedSecs, int rotationDegrees, bool isFlipped, double width, double height, { Object taskKey, int priority, }) { if (mimeType == MimeTypes.svg) { return Future.sync(() => null); } return servicePolicy.call( () async { try { final result = await platform.invokeMethod('getThumbnail', { 'uri': uri, 'mimeType': mimeType, 'dateModifiedSecs': dateModifiedSecs, 'rotationDegrees': rotationDegrees, 'isFlipped': isFlipped, 'widthDip': width, 'heightDip': height, 'defaultSizeDip': thumbnailDefaultSize, }); return result as Uint8List; } on PlatformException catch (e) { debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return null; }, // debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}', priority: priority ?? (width == 0 || height == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail), key: taskKey, ); } static Future clearSizedThumbnailDiskCache() async { try { return platform.invokeMethod('clearSizedThumbnailDiskCache'); } on PlatformException catch (e) { debugPrint('clearSizedThumbnailDiskCache failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } } static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]); static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); static Future resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); static Stream delete(Iterable entries) { try { return opChannel.receiveBroadcastStream({ 'op': 'delete', 'entries': entries.map(_toPlatformEntryMap).toList(), }).map((event) => ImageOpEvent.fromMap(event)); } on PlatformException catch (e) { debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}'); return Stream.error(e); } } static Stream move(Iterable entries, {@required bool copy, @required String destinationAlbum}) { debugPrint('move ${entries.length} entries'); try { return opChannel.receiveBroadcastStream({ 'op': 'move', 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, 'destinationPath': destinationAlbum, }).map((event) => MoveOpEvent.fromMap(event)); } on PlatformException catch (e) { debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}'); return Stream.error(e); } } static Future rename(ImageEntry entry, String newName) async { try { // return map with: 'contentId' 'path' 'title' 'uri' (all optional) final result = await platform.invokeMethod('rename', { 'entry': _toPlatformEntryMap(entry), 'newName': newName, }) as Map; return result; } on PlatformException catch (e) { debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return {}; } static Future rotate(ImageEntry entry, {@required bool clockwise}) async { try { // return map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('rotate', { 'entry': _toPlatformEntryMap(entry), 'clockwise': clockwise, }) as Map; return result; } on PlatformException catch (e) { debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return {}; } static Future flip(ImageEntry entry) async { try { // return map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('flip', { 'entry': _toPlatformEntryMap(entry), }) as Map; return result; } on PlatformException catch (e) { debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return {}; } } @immutable class ImageOpEvent { final bool success; final String uri; const ImageOpEvent({ this.success, this.uri, }); factory ImageOpEvent.fromMap(Map map) { return ImageOpEvent( success: map['success'] ?? false, uri: map['uri'], ); } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; return other is ImageOpEvent && other.success == success && other.uri == uri; } @override int get hashCode => hashValues(success, uri); @override String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}'; } class MoveOpEvent extends ImageOpEvent { final Map newFields; const MoveOpEvent({bool success, String uri, this.newFields}) : super( success: success, uri: uri, ); factory MoveOpEvent.fromMap(Map map) { return MoveOpEvent( success: map['success'] ?? false, uri: map['uri'], newFields: map['newFields'], ); } @override String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}'; } // cf flutter/foundation `consolidateHttpClientResponseBytes` typedef BytesReceivedCallback = void Function(int cumulative, int total); // cf flutter/foundation `consolidateHttpClientResponseBytes` class _OutputBuffer extends ByteConversionSinkBase { List> _chunks = >[]; int _contentLength = 0; Uint8List _bytes; @override void add(List chunk) { assert(_bytes == null); _chunks.add(chunk); _contentLength += chunk.length; } @override void close() { if (_bytes != null) { // We've already been closed; this is a no-op return; } _bytes = Uint8List(_contentLength); var offset = 0; for (final chunk in _chunks) { _bytes.setRange(offset, offset + chunk.length, chunk); offset += chunk.length; } _chunks = null; } Uint8List get bytes { assert(_bytes != null); return _bytes; } }