diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt index 3a46e1e1e..4d99242ea 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFetchBytesHandler.kt @@ -68,6 +68,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler { val uri = call.argument("uri")?.let { Uri.parse(it) } val mimeType = call.argument("mimeType") val pageId = call.argument("pageId") + val sizeBytes = call.argument("sizeBytes")?.toLong() val sampleSize = call.argument("sampleSize") val x = call.argument("regionX") val y = call.argument("regionY") @@ -85,6 +86,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler { when (mimeType) { MimeTypes.SVG -> SvgRegionFetcher(context).fetch( uri = uri, + sizeBytes = sizeBytes, regionRect = regionRect, imageWidth = imageWidth, imageHeight = imageHeight, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt index c8acbd44a..d09a6aa7e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/SvgRegionFetcher.kt @@ -23,11 +23,21 @@ class SvgRegionFetcher internal constructor( suspend fun fetch( uri: Uri, + sizeBytes: Long?, regionRect: Rect, imageWidth: Int, imageHeight: Int, result: MethodChannel.Result, ) { + if (sizeBytes != null && sizeBytes > FILE_SIZE_DANGER_THRESHOLD) { + val availableHeapSize = Runtime.getRuntime().let { it.maxMemory() - (it.totalMemory() - it.freeMemory()) } + if (sizeBytes > availableHeapSize) { + // opening an SVG that large would yield an OOM during parsing from `com.caverock.androidsvg.SVGParser` + result.error("fetch-read-large", "SVG too large at $sizeBytes bytes, with only $availableHeapSize free bytes, for uri=$uri regionRect=$regionRect", null) + return + } + } + var currentSvgRef = lastSvgRef if (currentSvgRef != null && currentSvgRef.uri != uri) { currentSvgRef = null @@ -103,4 +113,9 @@ class SvgRegionFetcher internal constructor( val uri: Uri, val svg: SVG, ) + + companion object { + // arbitrary size to detect files that may yield an OOM + private const val FILE_SIZE_DANGER_THRESHOLD = 10 * (1 shl 20) // MB + } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 8a2952972..54ac83280 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -117,16 +117,16 @@ object Metadata { return date.time + parseSubSecond(subSecond) } - // opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), + // Opening large PSD/TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1), // so we define an arbitrary threshold to avoid a crash on launch. // It is not clear whether it is because of the file itself or its metadata. - private const val fileSizeBytesMax = 100 * (1 shl 20) // MB + private const val FILE_SIZE_MAX = 100 * (1 shl 20) // MB - fun isDangerouslyLarge(sizeBytes: Long?) = sizeBytes == null || sizeBytes > fileSizeBytesMax + fun isDangerouslyLarge(sizeBytes: Long?) = sizeBytes == null || sizeBytes > FILE_SIZE_MAX // we try and read metadata from large files by copying an arbitrary amount from its beginning // to a temporary file, and reusing that preview file for all metadata reading purposes - private const val previewSize: Long = 5 * (1 shl 20) // MB + private const val PREVIEW_SIZE: Long = 5 * (1 shl 20) // MB private val previewFiles = HashMap() @@ -159,7 +159,7 @@ object Metadata { fun createPreviewFile(context: Context, uri: Uri): File { return File.createTempFile("aves", null, context.cacheDir).apply { deleteOnExit() - transferFrom(StorageUtils.openInputStream(context, uri), previewSize) + transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt index e1893729b..d4a7dfd2d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -45,9 +45,9 @@ object BitmapUtils { val bufferSize = stream.size() if (bufferSize > BUFFER_SIZE_DANGER_THRESHOLD) { - val availHeapSize = Runtime.getRuntime().let { it.maxMemory() - (it.totalMemory() - it.freeMemory()) } - if (bufferSize > availHeapSize) { - throw Exception("compressed bitmap to $bufferSize bytes, which cannot be allocated to a new byte array, with only $availHeapSize free bytes") + val availableHeapSize = Runtime.getRuntime().let { it.maxMemory() - (it.totalMemory() - it.freeMemory()) } + if (bufferSize > availableHeapSize) { + throw Exception("compressed bitmap to $bufferSize bytes, which cannot be allocated to a new byte array, with only $availableHeapSize free bytes") } } diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index fbb78fbda..135215324 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -42,6 +42,7 @@ class RegionProvider extends ImageProvider { key.region, key.imageSize, pageId: pageId, + sizeBytes: key.sizeBytes, taskKey: key, ); if (bytes.isEmpty) { @@ -70,7 +71,7 @@ class RegionProviderKey extends Equatable { // do not store the entry as it is, because the key should be constant // but the entry attributes may change over time final String uri, mimeType; - final int? pageId; + final int? pageId, sizeBytes; final int rotationDegrees, sampleSize; final bool isFlipped; final Rectangle region; @@ -83,13 +84,11 @@ class RegionProviderKey extends Equatable { required this.uri, required this.mimeType, required this.pageId, + required this.sizeBytes, required this.rotationDegrees, required this.isFlipped, required this.sampleSize, required this.region, required this.imageSize, }); - - @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 1d1e3c578..ffcb622f7 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -52,8 +52,8 @@ class UriImage extends ImageProvider with EquatableMixin { final bytes = await mediaFetchService.getImage( uri, mimeType, - rotationDegrees, - isFlipped, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, pageId: pageId, sizeBytes: sizeBytes, onBytesReceived: (cumulative, total) { @@ -76,7 +76,4 @@ class UriImage extends ImageProvider with EquatableMixin { unawaited(chunkEvents.close()); } } - - @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}'; } diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 256770f66..95a3e24a4 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -34,6 +34,7 @@ extension ExtraAvesEntryImages on AvesEntry { uri: uri, mimeType: mimeType, pageId: pageId, + sizeBytes: sizeBytes, rotationDegrees: rotationDegrees, isFlipped: isFlipped, sampleSize: sampleSize, diff --git a/lib/services/media/media_fetch_service.dart b/lib/services/media/media_fetch_service.dart index c3d13a55d..a746e4de5 100644 --- a/lib/services/media/media_fetch_service.dart +++ b/lib/services/media/media_fetch_service.dart @@ -17,17 +17,17 @@ abstract class MediaFetchService { Future getSvg( String uri, String mimeType, { - int? expectedContentLength, + required int? sizeBytes, BytesReceivedCallback? onBytesReceived, }); Future getImage( String uri, - String mimeType, - int? rotationDegrees, - bool isFlipped, { - int? pageId, - int? sizeBytes, + String mimeType, { + required int? rotationDegrees, + required bool isFlipped, + required int? pageId, + required int? sizeBytes, BytesReceivedCallback? onBytesReceived, }); @@ -40,7 +40,8 @@ abstract class MediaFetchService { int sampleSize, Rectangle regionRect, Size imageSize, { - int? pageId, + required int? pageId, + required int? sizeBytes, Object? taskKey, int? priority, }); @@ -93,26 +94,27 @@ class PlatformMediaFetchService implements MediaFetchService { Future getSvg( String uri, String mimeType, { - int? expectedContentLength, + required int? sizeBytes, BytesReceivedCallback? onBytesReceived, }) => getImage( uri, mimeType, - 0, - false, - sizeBytes: expectedContentLength, + rotationDegrees: 0, + isFlipped: false, + pageId: null, + sizeBytes: sizeBytes, onBytesReceived: onBytesReceived, ); @override Future getImage( String uri, - String mimeType, - int? rotationDegrees, - bool isFlipped, { - int? pageId, - int? sizeBytes, + String mimeType, { + required int? rotationDegrees, + required bool isFlipped, + required int? pageId, + required int? sizeBytes, BytesReceivedCallback? onBytesReceived, }) async { try { @@ -166,7 +168,8 @@ class PlatformMediaFetchService implements MediaFetchService { int sampleSize, Rectangle regionRect, Size imageSize, { - int? pageId, + required int? pageId, + required int? sizeBytes, Object? taskKey, int? priority, }) { @@ -176,6 +179,7 @@ class PlatformMediaFetchService implements MediaFetchService { final result = await _platformBytes.invokeMethod('getRegion', { 'uri': uri, 'mimeType': mimeType, + 'sizeBytes': sizeBytes, 'pageId': pageId, 'sampleSize': sampleSize, 'regionX': regionRect.left, diff --git a/lib/services/metadata/svg_metadata_service.dart b/lib/services/metadata/svg_metadata_service.dart index 50c6b8cb9..a792ef028 100644 --- a/lib/services/metadata/svg_metadata_service.dart +++ b/lib/services/metadata/svg_metadata_service.dart @@ -17,7 +17,11 @@ class SvgMetadataService { static Future getSize(AvesEntry entry) async { try { - final data = await mediaFetchService.getSvg(entry.uri, entry.mimeType); + final data = await mediaFetchService.getSvg( + entry.uri, + entry.mimeType, + sizeBytes: entry.sizeBytes, + ); final document = XmlDocument.parse(utf8.decode(data)); final root = document.rootElement; @@ -63,7 +67,11 @@ class SvgMetadataService { } try { - final data = await mediaFetchService.getSvg(entry.uri, entry.mimeType); + final data = await mediaFetchService.getSvg( + entry.uri, + entry.mimeType, + sizeBytes: entry.sizeBytes, + ); final document = XmlDocument.parse(utf8.decode(data)); final root = document.rootElement; diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 2dc018b92..439c06405 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -335,7 +335,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix MaterialPageRoute( settings: const RouteSettings(name: SourceViewerPage.routeName), builder: (context) => SourceViewerPage( - loader: () => mediaFetchService.getSvg(entry.uri, entry.mimeType).then(utf8.decode), + loader: () async { + final data = await mediaFetchService.getSvg( + entry.uri, + entry.mimeType, + sizeBytes: entry.sizeBytes, + ); + return utf8.decode(data); + }, ), ), ); diff --git a/lib/widgets/viewer/action/printer.dart b/lib/widgets/viewer/action/printer.dart index 7e8b9b4be..27f8a80ac 100644 --- a/lib/widgets/viewer/action/printer.dart +++ b/lib/widgets/viewer/action/printer.dart @@ -74,9 +74,13 @@ class EntryPrinter with FeedbackMixin { Future _buildPageImage(AvesEntry entry) async { if (entry.isSvg) { - final bytes = await mediaFetchService.getSvg(entry.uri, entry.mimeType); - if (bytes.isNotEmpty) { - return pdf.SvgImage(svg: utf8.decode(bytes)); + final data = await mediaFetchService.getSvg( + entry.uri, + entry.mimeType, + sizeBytes: entry.sizeBytes, + ); + if (data.isNotEmpty) { + return pdf.SvgImage(svg: utf8.decode(data)); } } else { return pdf.Image(await flutterImageProvider(entry.uriImage));