Files
buzz/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift
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

126 lines
6.8 KiB
Swift

// Concatenated with the production source by test-mobile-photo-zoom.sh so this
// fixture can exercise the private controller without widening its app API.
@main
struct NativePhotoZoomTests {
@MainActor static func main() {
if #available(iOS 18.0, *) {
let data = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in
UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
}
let viewer = NativePhotoZoomViewController(
image: UIImage(data: data)!, labels: (0..<100).map { "Photo \($0)" },
initialIndex: 0, canReply: false, canMore: false, canDelete: false
)
var requests: [(String, Int, FlutterResult)] = []
viewer.request = { method, index, _, completion in
if method == "select" { completion(nil) }
else { requests.append((method, index, completion)) }
}
viewer.loadViewIfNeeded()
viewer.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
viewer.view.layoutIfNeeded()
precondition(!requests.isEmpty && requests.allSatisfy { $0.1 <= 2 }, "Initial requests must stay adjacent")
let pager = viewer.children.compactMap { $0 as? UIPageViewController }.first!
let indicator = descendants(viewer.view).compactMap { $0 as? UIPageControl }.first!
let stale = requests
requests.removeAll()
indicator.currentPage = 50; sendValueChanged(indicator)
precondition(!requests.isEmpty && requests.allSatisfy { (48...52).contains($0.1) }, "Scrubbing must move the request window")
let page = pager.viewControllers!.first!
page.loadViewIfNeeded()
let photo = descendants(page.view).compactMap { $0 as? UIImageView }.first!
for request in stale { request.2(FlutterStandardTypedData(bytes: data)) }
precondition(photo.image == nil, "Late old-window responses cannot paint the selected photo")
let current = requests
requests.removeAll()
let blueData = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in
UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
}
let previous = photo.image
viewer.updateFrame(blueData, at: 0)
precondition(photo.image === previous, "Offscreen animation frames cannot paint the selected photo")
viewer.updateFrame(blueData, at: 50)
precondition(photo.image !== previous, "Continuing animation frames must update the selected photo")
let updated = photo.image
precondition(current.contains { $0.0 == "image" && $0.1 == 50 }, "Hold the selected initial-image reply until after a continuing frame")
for request in current { request.2(FlutterStandardTypedData(bytes: data)) }
precondition(photo.image === updated, "Delayed initial image and preview replies must not rewind a continuing frame")
viewer.updateFrame(Data([0, 1]), at: 50)
precondition(photo.image === updated, "Malformed frames must preserve the last displayed image")
indicator.currentPage = 0; sendValueChanged(indicator)
precondition(requests.contains { $0.1 == 0 }, "Leaving the window evicts old decoded images")
let closing = requests
requests.removeAll()
var closed = false
viewer.onClose = { _ in closed = true }
viewer.viewDidDisappear(false)
for request in closing { request.2(FlutterStandardTypedData(bytes: data)) }
precondition(closed && requests.isEmpty, "Teardown must ignore late loads and stop requesting")
let closedPage = pager.viewControllers!.first!
closedPage.loadViewIfNeeded()
let closedPhoto = descendants(closedPage.view).compactMap { $0 as? UIImageView }.first!
let closedImage = closedPhoto.image
viewer.updateFrame(data, at: 0)
precondition(closedPhoto.image === closedImage, "Closed viewers must ignore continuing frames")
print("PASS: native photo window, stale responses, eviction and teardown")
videoJourney(data: data)
}
}
@available(iOS 18.0, *)
@MainActor static func videoJourney(data: Data) {
let path = CommandLine.arguments[1]
let viewer = NativePhotoZoomViewController(image: UIImage(data: data)!,
labels: ["Clip", "Photo", "Second clip"], initialIndex: 0,
canReply: true, canMore: true, canDelete: false, videoIndices: [0, 2])
var requested: [Int] = []
var lateVideo: FlutterResult?
viewer.request = { method, index, _, completion in
if method == "video" {
requested.append(index)
if index == 0 { completion(path) } else { lateVideo = completion }
} else { completion(method == "select" ? nil : FlutterStandardTypedData(bytes: data)) }
}
viewer.loadViewIfNeeded()
viewer.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
viewer.view.layoutIfNeeded()
viewer.viewDidAppear(false)
let pager = viewer.children.compactMap { $0 as? UIPageViewController }.first!
let indicator = descendants(viewer.view).compactMap { $0 as? UIPageControl }.first!
let first = pager.viewControllers!.first!
first.loadViewIfNeeded()
let playerController = first.children.compactMap { $0 as? AVPlayerViewController }.first!
let player = playerController.player!
let item = player.currentItem!
let deadline = Date().addingTimeInterval(5)
while item.status == .unknown && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
precondition(item.status == .readyToPlay, "Real MP4 fixture must become playable")
precondition(requested == [0], "Only the selected video may download")
indicator.currentPage = 2; sendValueChanged(indicator)
precondition(player.currentItem == nil && playerController.player == nil, "Paging releases the old player before Dart deletes its file")
precondition(requested == [0, 2], "Paging requests the selected video")
indicator.currentPage = 0; sendValueChanged(indicator)
lateVideo?(path)
let current = pager.viewControllers!.first!
current.loadViewIfNeeded()
let active = current.children.compactMap { $0 as? AVPlayerViewController }.first!
precondition(active.player != nil, "Returning to a video restores its player")
viewer.viewDidDisappear(false)
precondition(active.player == nil, "Closing releases the player before returning to Flutter")
print("PASS: native video decoding, selected-only loading, paging and teardown")
}
@MainActor static func sendValueChanged(_ control: UIControl) {
// A command-line UIKit fixture has no UIApplication action dispatcher.
for target in control.allTargets {
for action in control.actions(forTarget: target, forControlEvent: .valueChanged) ?? [] {
_ = (target as AnyObject).perform(NSSelectorFromString(action), with: control)
}
}
}
@MainActor static func descendants(_ view: UIView) -> [UIView] {
[view] + view.subviews.flatMap { descendants($0) }
}
}