diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart index bb40c5b64b..c56f22cae3 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; -import 'package:bluebubbles/helpers/helpers.dart'; +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:flutter/cupertino.dart'; @@ -55,11 +55,12 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl final completer = Completer(); controller!.queueImage(Tuple4(attachment, file, context, completer)); final newData = await completer.future; - if (newData.isEmpty) return; + if (!mounted || newData.isEmpty) return; setState(() { data = newData; }); } else { + if (!mounted) return; setState(() { data = tmpData; }); @@ -78,13 +79,20 @@ class _ImageViewerState extends OptimizedState with AutomaticKeepAl height: min((attachment.height?.toDouble() ?? ns.width(context) * 0.5 / attachment.aspectRatio), ns.width(context) * 0.5 / attachment.aspectRatio), ); } + final decodeDimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: ns.width(context) * 0.5, + pixelRatio: Get.pixelRatio, + sourceWidth: attachment.width, + sourceHeight: attachment.height, + aspectRatio: attachment.aspectRatio, + ); return Image.memory( data!, // prevents the image widget from "refreshing" when the provider changes gaplessPlayback: true, filterQuality: FilterQuality.none, - cacheWidth: (min((attachment.width ?? 0), ns.width(context) * 0.5) * Get.pixelRatio / 2).round().abs().nonZero, - cacheHeight: (min((attachment.height ?? 0), ns.width(context) * 0.5 / attachment.aspectRatio) * Get.pixelRatio / 2).round().abs().nonZero, + cacheWidth: decodeDimensions.width, + cacheHeight: decodeDimensions.height, fit: BoxFit.cover, frameBuilder: (context, w, frame, wasSyncLoaded) { return AnimatedCrossFade( diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart new file mode 100644 index 0000000000..e113493868 --- /dev/null +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart @@ -0,0 +1,47 @@ +import 'dart:math'; + +class ImageViewerDecodeDimensions { + const ImageViewerDecodeDimensions( + {required this.width, required this.height}); + + final int width; + final int height; +} + +ImageViewerDecodeDimensions calculateImageViewerDecodeDimensions({ + required double maximumDisplayWidth, + required double pixelRatio, + required int? sourceWidth, + required int? sourceHeight, + required double aspectRatio, +}) { + final safeMaximumDisplayWidth = + maximumDisplayWidth.isFinite && maximumDisplayWidth > 0 + ? maximumDisplayWidth + : 1.0; + final safePixelRatio = + pixelRatio.isFinite && pixelRatio > 0 ? pixelRatio : 1.0; + final safeAspectRatio = + aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : 1.0; + final displayWidth = min( + sourceWidth != null && sourceWidth > 0 + ? sourceWidth.toDouble() + : safeMaximumDisplayWidth, + safeMaximumDisplayWidth, + ); + final fallbackHeight = safeMaximumDisplayWidth / safeAspectRatio; + final displayHeight = min( + sourceHeight != null && sourceHeight > 0 + ? sourceHeight.toDouble() + : fallbackHeight, + fallbackHeight, + ); + + int cacheDimension(double displayDimension) => + (displayDimension * safePixelRatio / 2).round().clamp(1, 1024).toInt(); + + return ImageViewerDecodeDimensions( + width: cacheDimension(displayWidth), + height: cacheDimension(displayHeight), + ); +} diff --git a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart index dec44c1892..f02e7975ad 100644 --- a/lib/app/layouts/fullscreen_media/fullscreen_holder.dart +++ b/lib/app/layouts/fullscreen_media/fullscreen_holder.dart @@ -14,6 +14,7 @@ import "package:flutter/material.dart"; import 'package:flutter/services.dart'; import 'package:gesture_x_detector/gesture_x_detector.dart'; import 'package:get/get.dart'; +import 'package:photo_view/photo_view.dart' show PhotoViewGestureDetectorScope; class FullscreenMediaHolder extends StatefulWidget { FullscreenMediaHolder({ @@ -151,7 +152,12 @@ class FullscreenMediaHolderState extends OptimizedState { } return KeyEventResult.ignored; }, - child: PageView.builder( + child: PhotoViewGestureDetectorScope( + // Lets a contained image yield horizontal drags to the + // PageView. A zoomed image keeps its own pan gesture, and + // FullscreenImage disables this PageView while zoomed. + axis: Axis.horizontal, + child: PageView.builder( physics: physics ?? (attachments.length == 1 ? const NeverScrollableScrollPhysics() @@ -302,6 +308,7 @@ class FullscreenMediaHolderState extends OptimizedState { ); } }, + ), ), ), ), diff --git a/test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart b/test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart new file mode 100644 index 0000000000..cdbf774648 --- /dev/null +++ b/test/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions_test.dart @@ -0,0 +1,43 @@ +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/image_viewer_decode_dimensions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('bounds decoded dimensions to the displayed image size', () { + final dimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: 200, + pixelRatio: 3, + sourceWidth: 4000, + sourceHeight: 3000, + aspectRatio: 4 / 3, + ); + + expect(dimensions.width, 300); + expect(dimensions.height, 225); + }); + + test('uses safe positive fallbacks for missing or invalid metadata', () { + final dimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: double.nan, + pixelRatio: double.infinity, + sourceWidth: 0, + sourceHeight: null, + aspectRatio: 0, + ); + + expect(dimensions.width, 1); + expect(dimensions.height, 1); + }); + + test('caps large device-scaled decode dimensions', () { + final dimensions = calculateImageViewerDecodeDimensions( + maximumDisplayWidth: 4000, + pixelRatio: 4, + sourceWidth: 4000, + sourceHeight: 4000, + aspectRatio: 1, + ); + + expect(dimensions.width, 1024); + expect(dimensions.height, 1024); + }); +}