392 lines
12 KiB
Dart
392 lines
12 KiB
Dart
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<AvesEntry?> getEntry(String uri, String? mimeType, {bool allowUnsized = false});
|
|
|
|
Future<Uint8List> getOriginalBytes(AvesEntry entry);
|
|
|
|
Future<ui.Codec> getFullImage({
|
|
required bool decoded,
|
|
required ImageRequest request,
|
|
required ImageDecoderCallback decode,
|
|
});
|
|
|
|
// `rect`: region to decode, with coordinates in reference to `imageSize`
|
|
Future<ui.Codec> getRegion({
|
|
required bool decoded,
|
|
required RegionProviderKey request,
|
|
required ImageDecoderCallback decode,
|
|
Object? taskKey,
|
|
int? priority,
|
|
});
|
|
|
|
Future<ui.Codec> getThumbnail({
|
|
required bool decoded,
|
|
required ThumbnailProviderKey request,
|
|
ImageDecoderCallback? decode,
|
|
Object? taskKey,
|
|
int? priority,
|
|
});
|
|
|
|
Future<void> clearImageDiskCache();
|
|
|
|
Future<void> clearImageMemoryCache();
|
|
|
|
bool cancelRegion(Object taskKey);
|
|
|
|
bool cancelThumbnail(Object taskKey);
|
|
|
|
Future<T>? resumeLoading<T>(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<AvesEntry?> getEntry(String uri, String? mimeType, {bool allowUnsized = false}) async {
|
|
try {
|
|
final result =
|
|
await _platformObject.invokeMethod('getEntry', <String, dynamic>{
|
|
'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<String, dynamic> _requestToArgs(ImageRequest request, {required bool decoded}) {
|
|
return <String, dynamic>{
|
|
'op': 'getFullImage',
|
|
'decoded': decoded,
|
|
'uri': request.uri,
|
|
'pageId': request.pageId,
|
|
'mimeType': request.mimeType,
|
|
'sizeBytes': request.sizeBytes,
|
|
'rotationDegrees': request.rotationDegrees ?? 0,
|
|
'isFlipped': request.isFlipped,
|
|
};
|
|
}
|
|
|
|
Future<Uint8List> _getBytes({
|
|
required String mimeType,
|
|
required Map<String, dynamic> arguments,
|
|
BytesReceivedCallback? onBytesReceived,
|
|
int? sizeBytes,
|
|
}) async {
|
|
var bytesReceived = 0;
|
|
final opCompleter = Completer<Uint8List>();
|
|
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<ui.Codec> _bytesToCodec(Map<String, dynamic> 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<Uint8List> 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<ui.Codec> 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<ui.Codec> getRegion({
|
|
required bool decoded,
|
|
required RegionProviderKey request,
|
|
required ImageDecoderCallback decode,
|
|
Object? taskKey,
|
|
int? priority,
|
|
}) {
|
|
final args = <String, dynamic>{
|
|
'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<ui.Codec> getThumbnail({
|
|
required bool decoded,
|
|
required ThumbnailProviderKey request,
|
|
ImageDecoderCallback? decode,
|
|
Object? taskKey,
|
|
int? priority,
|
|
}) {
|
|
final args = <String, dynamic>{
|
|
'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<void> clearImageDiskCache() async {
|
|
try {
|
|
return _platformObject.invokeMethod('clearImageDiskCache');
|
|
} on PlatformException catch (e, stack) {
|
|
await reportService.recordError(e, stack);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
|
|
|
|
// convenience methods
|
|
|
|
static const Set<String> _knownOpaqueImages = {
|
|
MimeTypes.jpeg,
|
|
};
|
|
|
|
static const Set<String> _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<String> _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<Object?> 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,
|
|
});
|
|
}
|