import 'dart:async'; import 'dart:ui' as ui; import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/model/app/support.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/channel.dart'; import 'package:aves/services/common/decoding.dart'; import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves_report/aves_report.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; abstract class MediaFetchService { Future getEntry(String uri, String? mimeType, {bool allowUnsized = false}); Future getOriginalBytes(AvesEntry entry); Future getFullImage({ required bool decoded, required ImageRequest request, required ImageDecoderCallback decode, }); // `rect`: region to decode, with coordinates in reference to `imageSize` Future getRegion({ required bool decoded, required RegionProviderKey request, required ImageDecoderCallback decode, Object? taskKey, int? priority, }); Future getThumbnail({ required bool decoded, required ThumbnailProviderKey request, ImageDecoderCallback? decode, Object? taskKey, int? priority, }); Future clearImageDiskCache(); Future clearImageMemoryCache(); bool cancelRegion(Object taskKey); bool cancelThumbnail(Object taskKey); Future? resumeLoading(Object taskKey); } class PlatformMediaFetchService implements MediaFetchService { static const _platformObject = AvesMethodChannel('deckers.thibault/aves/media_fetch_object'); static final _byteStream = AvesStreamsChannel('deckers.thibault/aves/media_byte_stream'); static const double _thumbnailDefaultSize = 64.0; static const int formatTrailerLength = 1; // single format byte static const int formatByteEncoded = 0xCA; static const int formatByteDecoded = 0xFE; @override Future getEntry(String uri, String? mimeType, {bool allowUnsized = false}) async { try { final result = await _platformObject.invokeMethod('getEntry', { 'uri': uri, 'mimeType': mimeType, 'allowUnsized': allowUnsized, }) as Map; AvesEntry.normalizeMimeTypeFields(result); return AvesEntry.fromMap(result); } on PlatformException catch (e, stack) { // ignore media content URIs as it is likely an obsolete Media Store entry if (!uri.startsWith('content://media/')) { // ignore undecodable types if (mimeType != null && !AppSupport.undecodableImages.contains(mimeType)) { await reportService.recordError(e, stack); } } } return null; } Map _requestToArgs(ImageRequest request, {required bool decoded}) { return { 'op': 'getFullImage', 'decoded': decoded, 'uri': request.uri, 'pageId': request.pageId, 'mimeType': request.mimeType, 'sizeBytes': request.sizeBytes, 'rotationDegrees': request.rotationDegrees ?? 0, 'isFlipped': request.isFlipped, }; } Future _getBytes({ required String mimeType, required Map arguments, BytesReceivedCallback? onBytesReceived, int? sizeBytes, }) async { var bytesReceived = 0; final opCompleter = Completer(); final sink = OutputBuffer(); try { _byteStream .receiveBroadcastStream(arguments) .listen( (data) { final chunk = data as Uint8List; sink.add(chunk); if (onBytesReceived != null) { bytesReceived += chunk.length; try { onBytesReceived(bytesReceived, sizeBytes); } catch (error, stack) { opCompleter.completeError(error, stack); return; } } }, onError: opCompleter.completeError, onDone: () { sink.close(); opCompleter.complete(sink.bytes); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below return await opCompleter.future; } on PlatformException catch (e, stack) { debugPrint('$runtimeType _getBytes failed with error=$e'); if (MimeTypes.isVisual(mimeType) && !_knownMediaTypes.contains(mimeType)) { await reportService.recordError(e, stack); } } return Uint8List(0); } Future _bytesToCodec(Map args, Uint8List bytes, ImageDecoderCallback? decode) async { final byteCount = bytes.lengthInBytes; if (byteCount < formatTrailerLength) { throw UnreportedStateError('failed to get image bytes for args=$args'); } final trailerOffset = byteCount - formatTrailerLength; final format = bytes[trailerOffset]; switch (format) { case formatByteEncoded: if (decode == null) { throw Exception('failed to decode encoded image bytes because decoder callback is missing for args=$args'); } // bytes are expected to be in a basic format decodable by Flutter final codec = await InteropDecoding.encodedBytesToCodec(bytes, decode); if (codec == null) { throw UnreportedStateError('failed to get codec from encoded image bytes for args=$args'); } return codec; case formatByteDecoded: // bytes are expected to be in ARGB_8888, necessary for wide gamut or HDR final descriptor = await InteropDecoding.rawBytesToDescriptor(bytes); if (descriptor == null) { throw UnreportedStateError('failed to get descriptor from decoded image bytes for args=$args'); } return descriptor.instantiateCodec(); default: throw UnreportedStateError('unsupported image format byte=$format for args=$args'); } } @override Future getOriginalBytes(AvesEntry entry) async { final request = ImageRequest( entry.uri, entry.mimeType, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, isAnimated: entry.isAnimated, pageId: entry.pageId, sizeBytes: entry.sizeBytes, ); final bytes = await _getBytes( mimeType: request.mimeType, arguments: _requestToArgs(request, decoded: false), onBytesReceived: request.onBytesReceived, sizeBytes: request.sizeBytes, ); final byteCount = bytes.lengthInBytes; if (byteCount <= formatTrailerLength) { throw UnreportedStateError('failed to get image bytes for request=$request'); } // trim custom trailer // a view does not reallocate memory and uses the underlying buffer return Uint8List.sublistView(bytes, 0, byteCount - formatTrailerLength); } @override Future getFullImage({ required bool decoded, required ImageRequest request, required ImageDecoderCallback decode, }) async { final args = _requestToArgs(request, decoded: decoded); final bytes = await _getBytes( mimeType: request.mimeType, arguments: args, onBytesReceived: request.onBytesReceived, sizeBytes: request.sizeBytes, ); return await _bytesToCodec(args, bytes, decode); } @override Future getRegion({ required bool decoded, required RegionProviderKey request, required ImageDecoderCallback decode, Object? taskKey, int? priority, }) { final args = { 'op': 'getRegion', 'decoded': decoded, 'uri': request.uri, 'pageId': request.pageId, 'mimeType': request.mimeType, 'sizeBytes': request.sizeBytes, 'sampleSize': request.sampleSize, 'regionX': request.regionRect.left, 'regionY': request.regionRect.top, 'regionWidth': request.regionRect.width, 'regionHeight': request.regionRect.height, 'imageWidth': request.imageSize.width.toInt(), 'imageHeight': request.imageSize.height.toInt(), }; return servicePolicy.call( () async { final bytes = await _getBytes( mimeType: request.mimeType, arguments: args, ); return await _bytesToCodec(args, bytes, decode); }, priority: priority ?? ServiceCallPriority.getRegion, key: taskKey, ); } @override Future getThumbnail({ required bool decoded, required ThumbnailProviderKey request, ImageDecoderCallback? decode, Object? taskKey, int? priority, }) { final args = { 'op': 'getThumbnail', 'decoded': decoded, 'uri': request.uri, 'pageId': request.pageId, 'mimeType': request.mimeType, 'dateModifiedMillis': request.dateModifiedMillis, 'rotationDegrees': request.rotationDegrees, 'isFlipped': request.isFlipped, 'widthDip': request.extent, 'heightDip': request.extent, 'defaultSizeDip': _thumbnailDefaultSize, 'quality': 100, }; return servicePolicy.call( () async { final bytes = await _getBytes( mimeType: request.mimeType, arguments: args, ); return await _bytesToCodec(args, bytes, decode); }, priority: priority ?? (request.extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail), key: taskKey, ); } @override Future clearImageDiskCache() async { try { return _platformObject.invokeMethod('clearImageDiskCache'); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } } @override Future clearImageMemoryCache() async { try { return _platformObject.invokeMethod('clearImageMemoryCache'); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } } @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); // convenience methods static const Set _knownOpaqueImages = { MimeTypes.jpeg, }; static const Set _knownVideos = { MimeTypes.v3gpp, MimeTypes.asf, MimeTypes.avi, MimeTypes.aviMSVideo, MimeTypes.aviVnd, MimeTypes.aviXMSVideo, MimeTypes.dl, MimeTypes.dv, MimeTypes.dvd, MimeTypes.flv, MimeTypes.flvX, MimeTypes.gl, MimeTypes.lsf, MimeTypes.mkv, MimeTypes.mkvX, MimeTypes.mov, MimeTypes.movX, MimeTypes.mp2p, MimeTypes.mp2t, MimeTypes.mp2ts, MimeTypes.mp4, MimeTypes.mpeg, MimeTypes.ogv, MimeTypes.realVideo, MimeTypes.webm, MimeTypes.wmv, }; static final Set _knownMediaTypes = { MimeTypes.anyImage, ..._knownOpaqueImages, ...MimeTypes.alphaImages, ...MimeTypes.rawImages, ...AppSupport.undecodableImages, MimeTypes.anyVideo, ..._knownVideos, }; } @immutable class ImageRequest extends Equatable { final String uri; final String mimeType; final int? rotationDegrees; final bool isFlipped; final bool isAnimated; final int? pageId; final int? sizeBytes; final BytesReceivedCallback? onBytesReceived; @override List get props => [uri, mimeType, rotationDegrees, isFlipped, isAnimated, pageId, sizeBytes, onBytesReceived]; const ImageRequest( this.uri, this.mimeType, { required this.rotationDegrees, required this.isFlipped, required this.isAnimated, required this.pageId, required this.sizeBytes, this.onBytesReceived, }); }