Files
buzz/mobile/test/features/channels/message_stack_cases.dart
klopez4212 b40f22114b Add video stacks and native iOS zoom playback (#9316)
## Summary

Videos previously rendered outside photo stacks and opened only the
Flutter video viewer. Message bubbles now group trailing videos and
photos in their original order, with poster thumbnails or a visible play
placeholder. On iOS 18+, the native zoom gallery (enabled by default)
opens both standalone videos and mixed stacks with AVKit playback
controls, and closing restores the selected stack item.

Flutter downloads only the selected video using fresh media
authentication, streams it to a bounded temporary file, and
cancels/deletes it when selection changes or the viewer closes. UIKit
releases the old player before that file is removed. Failed downloads
and playback expose Retry. Video URLs never enter the image decoder, and
photo-only actions are hidden on video pages.

The existing Flutter viewers remain the fallback. Native zoom defaults
on; `--dart-define=BUZZ_NATIVE_PHOTO_ZOOM=false` remains an explicit
build-time opt-out. Unsupported platforms/formats, unavailable native
presentation, and bridge failures use the Flutter fallback. Native video
pages use Close or accessibility escape to return with zoom; the
photo-specific downward drag recognizer is disabled there to avoid
competing with AVKit controls.

### Related issue

Stacked on #9314 (PNG/HEIC photo formats); merge that first. No matching
open video-stack/zoom PR found.

### Testing

- Default-on follow-up: presentation tests also cover unavailable, null,
and throwing bridge responses.

- `just mobile-check mobile-test`: analysis clean, 4,601 mobile tests
passed, plus push-configuration and secp256k1 package tests.
- Full native-enabled mobile suite: 4,626 passed, 68 skipped; rerun
passed at `dc752dccaa664cb56a19cbe517a266d1407a1100`.
- Mixed-stack widget journey covers order, video opening, returning to
selection, and the photo-only fallback gallery.
- Native bridge tests cover video snapshot presentation, selected-only
dispatch, stale sessions and keeping video URLs out of image decoding.
- Real HTTP download journey covers authenticated requests, Retry, byte
limits, cancellation and temporary-file cleanup.
- Native simulator fixture decodes a real MP4 and verifies selected-only
loading, player release on paging and teardown. Removing playback
cleanup makes the regression fail; restoring it passes.
- No physical-device installation or end-to-end zoom recording captured.

### Widget previews

Actual Flutter renders with sample video URLs and no poster metadata;
these show the stack layout, not device playback.

| Received, light | Sent, dark |
| --- | --- |
| ![Received video stack without poster
images](https://github.com/user-attachments/assets/af6d06a7-10e3-43c4-87de-52324be5b19c)
| ![Sent video stack without poster
images](https://github.com/user-attachments/assets/5b79b5b1-e3d4-4482-bbf6-574849d5dfdc)
|

### Human verification pending

On a normal build on iOS 18+ (no extra flag required), open a message
containing photos and videos, page between them, play a video, and
close. Verify playback controls work, only the selected video plays, and
closing returns to the selected stack item.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-10 10:03:56 -08:00

691 lines
25 KiB
Dart

part of 'message_content_test.dart';
void stackJourneyTests() {
testWidgets('mixed stacks keep video order and return from the fallback player', (
tester,
) async {
const clip = 'https://example.com/clip.mp4';
const photo = 'https://example.com/photo.png';
const poster = 'https://example.com/second-poster.png';
final requested = <String>[];
final client = MockClient((request) async {
requested.add(request.url.toString());
return http.Response.bytes(
base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEElEQVR4nGNgaPj/H4xhDABS0gn5PEa22gAAAABJRU5ErkJggg==',
),
200,
);
});
await tester.pumpWidget(
_testable(
const MessageContent(
bubble: true,
content:
'![Clip]($clip)\n![Photo]($photo)\n![Second](https://example.com/second.mov)',
tags: [
[
'imeta',
'url https://example.com/second.mov',
'm video/quicktime',
'image $poster',
],
],
),
disableAnimations: true,
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
),
);
await tester.pumpAndSettle();
expect(requested, contains(photo));
expect(requested, contains(poster));
expect(requested, isNot(contains(clip)));
expect(requested, isNot(contains('https://example.com/second.mov')));
final stack = find.byKey(const ValueKey('message-media-stack'));
final spoken = find.byKey(const ValueKey('message-media-stack-semantics'));
expect(find.text('3 attachments'), findsOneWidget);
expect(
find.byKey(const ValueKey('message-media-stack-video:$clip')),
findsOneWidget,
);
expect(tester.widget<Semantics>(spoken).properties.label, 'Clip');
expect(
tester.widget<Semantics>(spoken).properties.value,
'Attachment 1 of 3',
);
await tester.tap(stack);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-video-viewer')),
findsOneWidget,
);
await tester.tap(
find.byKey(const ValueKey('message-media-video-viewer-close')),
);
await tester.pumpAndSettle();
expect(tester.widget<Semantics>(spoken).properties.label, 'Clip');
await tester.drag(stack, const Offset(-160, 0));
await tester.pumpAndSettle();
expect(tester.widget<Semantics>(spoken).properties.label, 'Photo');
await tester.tap(stack);
await tester.pumpAndSettle();
expect(find.byType(MediaImageViewerPage), findsOneWidget);
expect(
tester
.widget<MediaImageViewerPage>(find.byType(MediaImageViewerPage))
.galleryItems!
.map((item) => item.url),
[photo],
);
Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop();
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Attachment 2 of 3',
);
});
testWidgets(
'sent and received stacks browse whole cards and open the selected gallery photo',
(tester) async {
_setSurfaceSize(tester, const Size(375, 800));
tester.view.devicePixelRatio = 3;
tester.view.physicalSize = const Size(1125, 2400);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final semantics = tester.ensureSemantics();
const first = 'https://example.com/stack-one.png';
const second = 'https://example.com/stack-two.png';
const third = 'https://example.com/stack-three.png';
for (final own in [false, true]) {
await tester.pumpWidget(
_testable(
MessageContent(
key: ValueKey(own),
bubble: true,
ownMessage: own,
content: '![one]($first)\n![two]($second)\n![three]($third)',
),
),
);
await tester.pumpAndSettle();
final stack = find.byKey(const ValueKey('message-media-stack'));
final spoken = find.byKey(
const ValueKey('message-media-stack-semantics'),
);
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 3',
);
expect(
tester
.getSemantics(spoken)
.getSemanticsData()
.hasAction(SemanticsAction.increase),
isTrue,
);
Finder cardClip(int index) => find.descendant(
of: find.byKey(ValueKey('stack-card:$index')),
matching: find.byType(ClipRSuperellipse),
);
{
Matrix4 pose(int index) => tester
.widgetList<Transform>(
find.descendant(
of: find.byKey(ValueKey('stack-card:$index')),
matching: find.byType(Transform),
),
)
.last
.transform;
// Three-photo stacks retain each reference's distinct middle and
// back slots, rather than giving both rear cards one pose.
expect(pose(1).storage[12], closeTo(own ? 12 : 11, .001));
expect(pose(1).storage[13], closeTo(-5, .001));
expect(pose(1).storage[1], greaterThan(0));
expect(pose(2).storage[12], closeTo(own ? -10 : -11, .001));
expect(pose(2).storage[13], closeTo(4, .001));
expect(pose(2).storage[1], lessThan(0));
}
final resting = tester.getRect(cardClip(0));
final viewport = tester.getRect(stack);
final countLabel = find.byKey(
const ValueKey('message-media-stack-count'),
);
final countBefore = tester.getRect(countLabel);
expect(find.text('3 photos'), findsOneWidget);
expect(
find.byKey(const ValueKey('message-media-stack-counter')),
findsNothing,
);
expect(
own ? viewport.right - resting.right : resting.left - viewport.left,
closeTo(Grid.xs + Grid.half + Grid.twelve, 1),
);
// Sample the complete path, including either side of the layer swap.
final sweep = await tester.startGesture(tester.getCenter(stack));
await sweep.moveBy(const Offset(-20, 0));
for (var step = 1; step < 100; step++) {
await sweep.moveBy(const Offset(-1.4, 0));
await tester.pump();
for (final element
in find
.descendant(
of: stack,
matching: find.byType(ClipRSuperellipse),
)
.evaluate()) {
final box = element.renderObject! as RenderBox;
for (final corner in [
Offset.zero,
Offset(box.size.width, 0),
Offset(0, box.size.height),
Offset(box.size.width, box.size.height),
]) {
expect(
viewport.contains(box.localToGlobal(corner)),
isTrue,
reason:
'phase ${step / 100}: ${box.localToGlobal(corner)} outside $viewport',
);
}
}
}
await sweep.moveBy(const Offset(138.6, 0));
await sweep.up();
await tester.pumpAndSettle();
final drag = await tester.startGesture(tester.getCenter(stack));
await drag.moveBy(const Offset(-20, 0));
await drag.moveBy(const Offset(-42, 0));
await tester.pump();
expect(
tester.getRect(countLabel),
countBefore,
reason: 'Photo count remains stationary during the swipe',
);
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
await drag.moveBy(const Offset(-28, 0));
await tester.pump();
final arriving = tester.getRect(cardClip(1));
// The solid cards meet at their edges when their paint order changes.
expect((arriving.center.dx - resting.center.dx).abs(), greaterThan(12));
final departing = tester.getRect(cardClip(0));
// The tuned rotation slightly narrows the projected outgoing card.
expect(departing.width, greaterThan(resting.width * .45));
expect(departing.width, lessThan(resting.width * .85));
expect(arriving.width, greaterThan(resting.width * .6));
expect(departing.intersect(arriving).width, lessThan(2));
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
expect(
find.descendant(of: stack, matching: find.byType(ClipPath)),
findsNothing,
);
// Every photo retains its own clip while the two front cards exchange.
final clips = find.descendant(
of: stack,
matching: find.byType(ClipRSuperellipse),
);
expect(clips, findsNWidgets(3));
for (final clip in tester.widgetList<ClipRSuperellipse>(clips)) {
expect(
clip.borderRadius,
BorderRadius.circular(Radii.container + Grid.xxs - Grid.half),
);
}
final stage = tester.getRect(stack);
for (final element in clips.evaluate()) {
final box = element.renderObject! as RenderBox;
for (final corner in [
Offset.zero,
Offset(box.size.width, 0),
Offset(0, box.size.height),
Offset(box.size.width, box.size.height),
]) {
expect(
stage.contains(box.localToGlobal(corner)),
isTrue,
reason: '${box.localToGlobal(corner)} outside $stage',
);
}
}
await drag.moveBy(const Offset(-28, 0));
await tester.pump();
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
await drag.up();
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 3',
);
// A canceled touch that never became a drag must not restore an old
// drag origin after a gallery changes the selected photo.
final canceledTouch = await tester.startGesture(
tester.getCenter(stack),
);
await canceledTouch.cancel();
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Photo 2 of 3',
);
await tester.tap(stack);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsOneWidget,
);
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
1,
);
final viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
final preview = viewer.galleryItems![1];
final cardImage = tester.widget<MediaImage>(
find
.descendant(
skipOffstage: false,
of: find.byKey(
const ValueKey('stack-card:1'),
skipOffstage: false,
),
matching: find.byType(MediaImage, skipOffstage: false),
)
.first,
);
expect(cardImage.decodeWidth, lessThanOrEqualTo(210));
expect(preview.previewDecodeWidth, cardImage.decodeWidth);
final resized = preview.preloadProvider! as ResizeImage;
expect(resized.width, (cardImage.decodeWidth! * 3).ceil());
// Page in the full-screen gallery, close, then reopen the same photo.
await tester.drag(find.byType(PageView), const Offset(-350, 0));
await tester.pumpAndSettle();
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
2,
);
await tester.tap(
find.byKey(const ValueKey('message-media-image-viewer-close')),
);
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Photo 3 of 3',
);
await tester.tap(stack);
await tester.pumpAndSettle();
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
2,
);
await tester.drag(find.byType(PageView), const Offset(350, 0));
await tester.pumpAndSettle();
// System back must preserve selection as well as the Close control.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Photo 2 of 3',
);
await tester.drag(stack, const Offset(100, 0));
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 3',
);
final reverse = await tester.startGesture(tester.getCenter(stack));
await reverse.moveBy(const Offset(-20, 0));
await reverse.moveBy(const Offset(-65, 0));
await tester.pump();
await reverse.moveBy(const Offset(65, 0));
await reverse.up();
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 3',
);
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
}
// Larger collections keep their rear cards quiet rather than multiplying
// the fan angle with every additional photo.
for (final own in [false, true]) {
await tester.pumpWidget(
_testable(
MessageContent(
key: ValueKey(own),
ownMessage: own,
bubble: true,
content: List.filled(6, '![photo]($first)').join('\n'),
),
),
);
await tester.pumpAndSettle();
final largeStack = find.byKey(const ValueKey('message-media-stack'));
for (final element
in find
.descendant(
of: largeStack,
matching: find.byType(ClipRSuperellipse),
)
.evaluate()) {
final box = element.renderObject! as RenderBox;
final edge =
box.localToGlobal(Offset(box.size.width, 0)) -
box.localToGlobal(Offset.zero);
expect((edge.dy / edge.dx).abs(), lessThan(own ? .12 : .2));
}
Finder rearClip(int index) => find.descendant(
of: find.byKey(ValueKey('stack-card:$index')),
matching: find.byType(ClipRSuperellipse),
);
final rearBefore = {
for (final i in [2, 3]) i: tester.getRect(rearClip(i)),
};
final rearCenters =
rearBefore.values.map((rect) => rect.center.dx).toList()..sort();
expect(
rearCenters.last - rearCenters.first,
lessThan(1),
reason: 'Large collections use compact backing tiers',
);
List<Key?> rearPaintOrder() => tester
.widget<Stack>(
find.byKey(const ValueKey('message-media-card-deck')),
)
.children
.map((child) => child.key)
.where((key) => [2, 3].any((i) => key == ValueKey('stack-card:$i')))
.toList();
final stableRearOrder = rearPaintOrder();
final deckDrag = await tester.startGesture(
tester.getCenter(largeStack),
);
await deckDrag.moveBy(const Offset(-20, 0));
void checkDeckBounds() {
final stage = tester.getRect(largeStack);
for (final element
in find
.descendant(
of: largeStack,
matching: find.byType(ClipRSuperellipse),
)
.evaluate()) {
final box = element.renderObject! as RenderBox;
for (final corner in [
Offset.zero,
Offset(box.size.width, 0),
Offset(0, box.size.height),
Offset(box.size.width, box.size.height),
]) {
expect(stage.contains(box.localToGlobal(corner)), isTrue);
}
}
}
for (var step = 0; step < 50; step++) {
await deckDrag.moveBy(const Offset(-1.4, 0));
await tester.pump();
checkDeckBounds();
expect(
rearPaintOrder(),
stableRearOrder,
reason: 'Rear cards must not swap layers during a handoff',
);
}
expect(
find.descendant(
of: largeStack,
matching: find.byType(ClipRSuperellipse),
),
findsNWidgets(4),
);
for (final entry in rearBefore.entries) {
final moved = tester.getRect(rearClip(entry.key));
expect(
moved.width,
lessThan(entry.value.width * .9),
reason: 'Rear card ${entry.key} must turn with the deck',
);
expect(
(moved.center.dx - entry.value.center.dx).abs(),
greaterThan(8),
reason: 'Rear card ${entry.key} must travel with the deck',
);
}
for (var step = 0; step < 49; step++) {
await deckDrag.moveBy(const Offset(-1.4, 0));
await tester.pump();
checkDeckBounds();
expect(
rearPaintOrder(),
stableRearOrder,
reason: 'Rear cards must not swap layers during a handoff',
);
}
// The spring can render arbitrarily close to the next integer. The
// arriving photo must remain in front until and after that boundary.
await deckDrag.moveBy(const Offset(-1.393, 0));
await tester.pump();
final nearSettledDeck = tester.widget<Stack>(
find.byKey(const ValueKey('message-media-card-deck')),
);
final photoKeys = nearSettledDeck.children
.map((child) => child.key)
.where(
(key) =>
key is ValueKey<String> &&
key.value.startsWith('stack-card:'),
);
expect(
photoKeys.last,
const ValueKey('stack-card:1'),
reason: 'Do not flash the departing image just before settling',
);
double shadowStrength() => tester
.widgetList<DecoratedBox>(
find.descendant(
of: largeStack,
matching: find.byType(DecoratedBox),
),
)
.map((box) => box.decoration)
.whereType<ShapeDecoration>()
.expand((decoration) => decoration.shadows ?? <BoxShadow>[])
.fold(0.0, (sum, shadow) => sum + shadow.color.a);
final shadowBefore = shadowStrength();
expect(shadowBefore, closeTo(3 * 48 / 255, .001));
final outgoingBox = tester.renderObject<RenderBox>(rearClip(0));
final coveringBox = tester.renderObject<RenderBox>(rearClip(3));
for (final corner in [
Offset.zero,
Offset(outgoingBox.size.width, outgoingBox.size.height),
]) {
expect(
(outgoingBox.localToGlobal(corner) -
coveringBox.localToGlobal(corner))
.distance,
lessThan(.01),
reason: 'Outgoing image is fully covered before removal',
);
}
await deckDrag.up();
await tester.pumpAndSettle();
expect(
shadowStrength(),
closeTo(shadowBefore, .001),
reason: 'The rear shadow must survive the integer handoff',
);
expect(find.byKey(const ValueKey('stack-card:0')), findsNothing);
expect(find.byKey(const ValueKey('stack-card:3')), findsOneWidget);
// Start each next swipe while the previous spring is still running.
for (final dx in [-90.0, -90.0, 90.0, -90.0, 90.0, 90.0]) {
await tester.fling(largeStack, Offset(dx, 0), 2000);
for (var frame = 0; frame < 3; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final value = tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value!;
final selected = int.parse(value.split(' ')[1]) - 1;
final keys = tester
.widget<Stack>(
find.byKey(const ValueKey('message-media-card-deck')),
)
.children
.map((child) => child.key)
.where(
(key) =>
key is ValueKey<String> &&
key.value.startsWith('stack-card:'),
);
expect(keys.last, ValueKey('stack-card:$selected'));
expect(tester.takeException(), isNull);
}
}
await tester.pumpAndSettle();
}
await tester.pumpWidget(
_testable(
const MessageContent(
bubble: true,
content: '![one]($first)\n![duplicate]($first)',
),
disableAnimations: true,
),
);
await tester.pumpAndSettle();
final spoken = find.byKey(
const ValueKey('message-media-stack-semantics'),
);
final node = tester.getSemantics(spoken);
node.owner!.performAction(node.id, SemanticsAction.decrease);
await tester.pump();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 2',
);
expect(tester.binding.hasScheduledFrame, isFalse);
expect(tester.takeException(), isNull);
semantics.dispose();
},
);
testWidgets('stack tuner adjusts slots independently and resets', (
tester,
) async {
String? copiedSettings;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copiedSettings = (call.arguments as Map)['text'] as String;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
_setSurfaceSize(tester, const Size(430, 932));
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_testable(
MessageContent(
bubble: true,
ownMessage: true,
content: List.filled(
6,
'![photo](https://example.com/tuning.png)',
).join('\n'),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Tune stack'));
await tester.pumpAndSettle();
final sheet = find.byKey(const ValueKey('stack-tuning-sheet'));
Finder clip(int i) => find.descendant(
of: find.descendant(
of: sheet,
matching: find.byKey(ValueKey('stack-card:$i')),
),
matching: find.byType(ClipRSuperellipse),
);
final first = tester.getRect(clip(0));
final second = tester.getRect(clip(1));
final third = tester.getRect(clip(2));
await tester.tap(find.text('Card 2'));
await tester.pumpAndSettle();
final sliders = find.descendant(of: sheet, matching: find.byType(Slider));
await tester.drag(sliders.at(0), const Offset(45, 0));
await tester.pumpAndSettle();
expect(
tester.getRect(clip(1)).center.dx,
greaterThan(second.center.dx + 1),
);
expect(tester.getRect(clip(0)), first);
expect(tester.getRect(clip(2)), third);
await tester.drag(sliders.at(1), const Offset(35, 0));
await tester.pumpAndSettle();
expect(
tester.getRect(clip(1)).center.dy,
greaterThan(second.center.dy + 1),
);
await tester.drag(sliders.at(2), const Offset(35, 0));
await tester.pumpAndSettle();
expect(tester.widget<Slider>(sliders.at(2)).value, greaterThan(0));
await tester.tap(find.text('Copy settings'));
await tester.pumpAndSettle();
expect(copiedSettings, isNotNull);
final exported = jsonDecode(copiedSettings!) as List;
expect(exported[0]['x'], 0);
expect(exported[1]['x'], greaterThan(0));
expect(exported[2]['x'], 0);
await tester.tap(find.text('Reset all'));
await tester.pumpAndSettle();
expect(tester.getRect(clip(1)), second);
expect(tester.widget<Slider>(sliders.at(0)).value, 0);
expect(tester.widget<Slider>(sliders.at(1)).value, 0);
expect(tester.widget<Slider>(sliders.at(2)).value, 0);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(tester.takeException(), isNull);
}, skip: !const bool.fromEnvironment('BUZZ_PHOTO_STACK_TUNING'));
}