mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
## Summary Photos currently filters out videos, video messages without poster metadata stay grey until playback, and the native gallery rejects downward dismissal on video pages. - Include photos and videos in the native inline/popover pickers, system picker, and recent-media grid. Mixed selections retain their order and videos use the video upload path. Remove the redundant Video menu row on iOS and Android, whose Photos picker accepts both media types; reduce their menu height to four rows. Native pickers request the current asset representation to avoid compatibility transcoding and export up to three files concurrently, preserving order and cleaning up cancelled/failed batches. - Show local stills for queued videos. For older messages without posters, serialize thumbnail generation for the four rendered stack cards, bound/cancel downloads, remove temporary videos, and save stills in the existing account/community cache. Existing poster metadata is used directly. The native viewer uses the poster while loading instead of a play-button snapshot. - Allow downward dismissal on native video pages while preserving horizontal paging/scrubbing, zoomed-photo panning, and the accessible Close button. Fire selection-click haptics as a swipe begins rather than after the page settles, with no duplicate on completion or cancellation. Page-indicator changes retain immediate feedback. Replace visible video-loading text with the existing white arc-spinner treatment, implemented natively with reduced-motion and lifecycle handling. Completed swipes release the previous video player even after UIKit removes it from the active children. Legacy videos need one initial download (up to the existing 100 MB limit) to generate their cached preview. This does not start playback. ### Related issue Follow-up to merged #9259 and #9314. Searched open and closed gallery/video issues and PRs; no duplicate fix found. ### Testing - Full `just mobile-check` and `just mobile-test` passed at `12e6ed6ae3`: 4,688 Flutter tests (71 skipped), 4 unconfigured-push tests, and 27 crypto-package tests. File-size checks passed. Regressions cover mixed image/video upload routing, queued still previews, the rendered-card poster window, authenticated/cancelled downloads, caching/cleanup, and native poster loading. - Native gallery simulator regression fixture passed: photo/video dismissal guards, real MP4 decoding, paging, selected-only playback loading, and teardown. - Swift type-check of the native picker and its dependencies passed, with existing deprecation/capture warnings. - `just ci` passed its repository-wide lint/type checks. The unrelated Rust test rebuild was stopped after consuming about 13 GB to preserve remaining disk space; only that run's generated caches were removed. Release build at `fb09f9cb8` installed in place and launched on the physical iPhone Air as **Buzz Photo Stack**, with `BUZZ_NATIVE_PHOTO_ZOOM=true`. Existing app updated in place with the same bundle and Keychain identity; no uninstall or reset. Owner tested the physical-device preview and approved readiness for review. No before/after capture was produced. The simulator evidence above is an automated native fixture, not a full Buzz device journey. Native preparation follow-up: `scripts/test-mobile-picker-preparation.sh` passed on an iPhone Air simulator, covering bounded overlap, out-of-order completion, cancellation, and failure cleanup. The updated physical Air preview launched successfully with unchanged preferences metadata. Cloud-only assets may still require preparation. Loader/haptics follow-up native fixture passed: real video readiness hides the loader, error/retry and detach stop/restart it correctly, swipe start triggers one haptic before selection changes, and completion/cancellation/duplicate-start callbacks add none. Updated release installed and launched on the physical Air; owner approved the final preview for review. Review: focused code review and native regression checks completed; physical Air preview tested by the owner, who approved readiness. P2 follow-up: video presentation uses an already decoded poster or the source snapshot immediately, then forwards a pending poster asynchronously. Late poster updates are limited to the selected native page and do not restart playback. Added pending-provider presentation coverage and native late/stale-poster checks. This follow-up has not been installed on the physical preview yet. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
201 lines
12 KiB
Swift
201 lines
12 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 location = CGPoint(x: 195, y: 400)
|
|
precondition(viewer.shouldBeginDismissal(location: location, velocity: CGVector(dx: 0, dy: 300)), "Resting photos dismiss downward")
|
|
precondition(!viewer.shouldBeginDismissal(location: location, velocity: CGVector(dx: 300, dy: 10)), "Horizontal swipes must page")
|
|
let scroll = descendants(pager.viewControllers!.first!.view).compactMap { $0 as? UIScrollView }.first!
|
|
scroll.zoomScale = 2
|
|
precondition(!viewer.shouldBeginDismissal(location: location, velocity: CGVector(dx: 0, dy: 300)), "Zoomed photos must pan instead of dismiss")
|
|
scroll.zoomScale = 1
|
|
|
|
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)
|
|
loaderJourney()
|
|
|
|
}
|
|
}
|
|
@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])
|
|
let feedback = GalleryFeedbackSpy()
|
|
viewer.selectionFeedback = feedback
|
|
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!
|
|
precondition(viewer.shouldBeginDismissal(location: CGPoint(x: 195, y: 400), velocity: CGVector(dx: 0, dy: 300)), "Videos must dismiss downward too")
|
|
precondition(!viewer.shouldBeginDismissal(location: CGPoint(x: 195, y: 400), velocity: CGVector(dx: 300, dy: 0)), "Video horizontal scrubbing must not dismiss")
|
|
let first = pager.viewControllers!.first!
|
|
first.loadViewIfNeeded()
|
|
let posterView = descendants(first.view).compactMap { $0 as? UIImageView }.first!
|
|
let oldPoster = posterView.image
|
|
viewer.updateFrame(data, at: 0)
|
|
precondition(posterView.image !== oldPoster, "A late poster updates the selected video")
|
|
let selectedPoster = posterView.image
|
|
viewer.updateFrame(data, at: 2)
|
|
precondition(posterView.image === selectedPoster, "A stale poster cannot replace the selected video")
|
|
let playerController = first.children.compactMap { $0 as? AVPlayerViewController }.first!
|
|
let player = playerController.player!
|
|
let item = player.currentItem!
|
|
let firstSpinner = descendants(first.view).compactMap { $0 as? NativeGalleryLoadingIndicator }.first!
|
|
let deadline = Date().addingTimeInterval(5)
|
|
while (item.status == .unknown || !firstSpinner.isHidden) && Date() < deadline {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
|
|
}
|
|
precondition(item.status == .readyToPlay, "Real MP4 fixture must become playable")
|
|
precondition(firstSpinner.isHidden, "Playable video removes the loader")
|
|
precondition(requested == [0], "Only the selected video may download")
|
|
precondition(feedback.clicks == 0, "Opening must not trigger selection haptics")
|
|
viewer.pageViewController(pager, willTransitionTo: [first])
|
|
precondition(feedback.clicks == 1, "Swipe feedback fires before the page changes")
|
|
viewer.pageViewController(pager, willTransitionTo: [first])
|
|
precondition(feedback.clicks == 1, "Repeated start callbacks do not duplicate swipe feedback")
|
|
viewer.pageViewController(pager, didFinishAnimating: true, previousViewControllers: [first], transitionCompleted: false)
|
|
precondition(feedback.clicks == 1, "Cancellation does not add a second haptic")
|
|
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")
|
|
precondition(feedback.clicks == 2, "Changing to a video triggers one selection click")
|
|
let loadingPage = pager.viewControllers!.first!
|
|
loadingPage.loadViewIfNeeded()
|
|
let spinner = descendants(loadingPage.view).compactMap { $0 as? NativeGalleryLoadingIndicator }.first!
|
|
precondition(!spinner.isHidden && spinner.accessibilityLabel == "Loading video", "Show the shared arc treatment while loading")
|
|
precondition(!descendants(loadingPage.view).compactMap { $0 as? UILabel }.contains { $0.text == "Loading video…" }, "No visible loading copy")
|
|
lateVideo?(nil)
|
|
precondition(spinner.isHidden, "Failure stops the loader")
|
|
let retry = descendants(loadingPage.view).compactMap { $0 as? UIButton }.first { $0.currentTitle == "Retry video" }!
|
|
precondition(!retry.isHidden, "Failure retains retry")
|
|
for target in retry.allTargets {
|
|
for action in retry.actions(forTarget: target, forControlEvent: .touchUpInside) ?? [] {
|
|
_ = (target as AnyObject).perform(NSSelectorFromString(action), with: retry)
|
|
}
|
|
}
|
|
precondition(!spinner.isHidden && requested == [0, 2, 2], "Retry restarts the loader")
|
|
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")
|
|
precondition(spinner.isHidden, "Leaving a loading video stops its loader")
|
|
let next = viewer.pageViewController(pager, viewControllerAfter: current)!
|
|
viewer.pageViewController(pager, willTransitionTo: [next])
|
|
precondition(feedback.clicks == 4, "Next swipe fires before the new photo is selected")
|
|
pager.setViewControllers([next], direction: .forward, animated: false)
|
|
viewer.pageViewController(pager, didFinishAnimating: true, previousViewControllers: [current], transitionCompleted: true)
|
|
precondition(feedback.clicks == 4, "Completed swipe does not add a delayed haptic")
|
|
viewer.pageViewController(pager, didFinishAnimating: true, previousViewControllers: [current], transitionCompleted: true)
|
|
precondition(feedback.clicks == 4, "Repeated callbacks for the same page do not duplicate haptics")
|
|
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 loaderJourney() {
|
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
|
|
let root = UIViewController(); window.rootViewController = root
|
|
let loader = NativeGalleryLoadingIndicator(frame: CGRect(x: 28, y: 28, width: 44, height: 44))
|
|
root.view.addSubview(loader); window.isHidden = false
|
|
loader.setLoading(true); loader.layoutIfNeeded()
|
|
if !UIAccessibility.isReduceMotionEnabled {
|
|
precondition(loader.layer.animation(forKey: "loading")?.duration == 0.5, "Match the shared spinner cadence")
|
|
}
|
|
loader.setLoading(false)
|
|
precondition(loader.layer.animation(forKey: "loading") == nil && loader.isHidden)
|
|
loader.setLoading(true); loader.removeFromSuperview()
|
|
precondition(loader.layer.animation(forKey: "loading") == nil, "Detached loaders must stop animating")
|
|
window.isHidden = true
|
|
}
|
|
@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) }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class GalleryFeedbackSpy: UISelectionFeedbackGenerator {
|
|
var clicks = 0
|
|
override func selectionChanged() { clicks += 1 }
|
|
}
|