diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 25b377f846..bb62f49e9b 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -206,6 +206,7 @@ jobs: xcrun simctl boot "$device" xcrun simctl bootstatus "$device" -b /bin/bash scripts/test-mobile-pagination-accessibility.sh "$device" + /bin/bash scripts/test-mobile-carousel.sh "$device" /bin/bash scripts/test-mobile-member-picker.sh "$device" xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner \ -configuration Debug -destination "platform=iOS Simulator,id=$device" \ diff --git a/mobile/ios/NativeControlTests/IosMediaCarouselTests.swift b/mobile/ios/NativeControlTests/IosMediaCarouselTests.swift new file mode 100644 index 0000000000..a2eeff733c --- /dev/null +++ b/mobile/ios/NativeControlTests/IosMediaCarouselTests.swift @@ -0,0 +1,125 @@ +import Flutter +import UIKit + +@main +struct IosMediaCarouselTests { + @MainActor static func main() { + let messenger = CarouselTestMessenger() + var args: [String: Any] = [ + "labels": ["Photo A", "Photo B", "Photo C"], + "width": 300.0, "gutter": 20.0, "gap": 4.0, "trailingPadding": 20.0, "radius": 16.0, + ] + let platform = IosMediaCarousel( + frame: CGRect(x: 0, y: 0, width: 340, height: 240), id: 42, + arguments: args, messenger: messenger + ) + guard let strip = platform.view() as? UICollectionView else { + preconditionFailure("Carousel must use native collection scrolling") + } + messenger.configure(args) + strip.layoutIfNeeded() + precondition(strip.numberOfItems(inSection: 0) == 3) + precondition(strip.contentOffset.x == -20, "Initial photo starts at gutter") + precondition(strip.decelerationRate == .fast) + let first = IndexPath(item: 0, section: 0) + guard let cell = strip.cellForItem(at: first) else { + preconditionFailure("Missing photo cell") + } + precondition(cell.accessibilityLabel == "Open Photo A") + precondition(cell.accessibilityTraits.contains(.button)) + precondition(messenger.calls.contains { $0.method == "subscribeImage" }, "Images requested after ready") + guard let subscription = messenger.calls.first(where: { + $0.method == "subscribeImage" && ($0.arguments as? [String: Any])?["index"] as? Int == 0 + }), let id = (subscription.arguments as? [String: Any])?["id"] as? String, + let photo = cell.contentView.subviews.compactMap({ $0 as? UIImageView }).first else { + preconditionFailure("Missing visible photo subscription") + } + func frame(_ color: UIColor) -> Data { + UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in + color.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) + } + } + messenger.frame(id, frame(.red)) + let firstFrame = photo.image?.pngData() + messenger.frame(id, frame(.blue)) + precondition(firstFrame != nil && photo.image?.pngData() != firstFrame, + "Native photo must display continuing animation frames") + precondition(subscription.arguments is [String: Any], "Subscriptions carry request IDs") + platform.collectionView(strip, didEndDisplaying: cell, forItemAt: first) + precondition(messenger.calls.last?.method == "cancelImage", "Disappearing cells cancel subscriptions") + let stoppedFrame = photo.image?.pngData() + messenger.frame(id, frame(.green)) + precondition(photo.image?.pngData() == stoppedFrame, "Late cancelled frames must not paint") + platform.collectionView(strip, willDisplay: cell, forItemAt: first) + let resubscription = messenger.calls.last + cell.prepareForReuse() + precondition(messenger.calls.last?.method == "cancelImage", "Reuse cancels its previous subscription") + precondition((resubscription?.arguments as? [String: Any])?["id"] as? String == messenger.calls.last?.arguments as? String) + platform.collectionView(strip, didSelectItemAt: IndexPath(item: 1, section: 0)) + precondition(messenger.calls.last?.method == "open") + precondition(messenger.calls.last?.arguments as? Int == 1) + var target = CGPoint(x: 235, y: 0) + platform.scrollViewWillEndDragging(strip, withVelocity: CGPoint(x: 1, y: 0), targetContentOffset: &target) + precondition(target.x == 250, "Drag snaps to native item stride") + target.x = 10000 + platform.scrollViewWillEndDragging(strip, withVelocity: .zero, targetContentOffset: &target) + precondition(target.x <= strip.contentSize.width - strip.bounds.width + 20, "No trailing blank page") + let last = IndexPath(item: 2, section: 0) + guard let lastFrame = strip.layoutAttributesForItem(at: last)?.frame else { + preconditionFailure("Missing final photo layout") + } + let trailingEdge = lastFrame.maxX - target.x + precondition(abs((strip.bounds.width - 20) - trailingEdge - 20) < 0.01, + "Final photo must stop at the full 20pt message gutter, not the 4pt photo gap") + precondition(lastFrame.minX == 540, "Trailing padding must not change page stride") + strip.contentOffset.x = 250 + platform.scrollViewDidScroll(strip) + precondition(messenger.calls.contains { $0.method == "page" && $0.arguments as? Int == 1 }) + args["rtl"] = true + messenger.configure(args) + precondition(strip.transform.a == -1, "Native strip follows RTL reading order") + strip.layoutIfNeeded() + let rtlCell = strip.visibleCells.first + precondition(rtlCell?.transform.a == -1, "Photo itself is not mirrored") + target.x = 10000 + platform.scrollViewWillEndDragging(strip, withVelocity: .zero, targetContentOffset: &target) + guard let rtlLast = strip.layoutAttributesForItem(at: last)?.frame else { + preconditionFailure("Missing RTL final photo layout") + } + precondition(abs((strip.bounds.width - 20) - (rtlLast.maxX - target.x) - 20) < 0.01, + "RTL final photo must retain the same message gutter") + args["width"] = 200.0 + messenger.configure(args) + precondition(strip.contentOffset.x == 160, "Resize retains the selected logical photo") + print("Native carousel scrolling, bounds, RTL, image requests, and taps passed") + } +} + +private final class CarouselTestMessenger: NSObject, FlutterBinaryMessenger { + var calls: [FlutterMethodCall] = [] + var handler: FlutterBinaryMessageHandler? + func frame(_ id: String, _ data: Data) { + let codec = FlutterStandardMethodCodec.sharedInstance() + handler?(codec.encode(FlutterMethodCall(methodName: "imageFrame", arguments: [ + "id": id, "bytes": FlutterStandardTypedData(bytes: data) + ])), { _ in }) + } + func configure(_ args: [String: Any]) { + let codec = FlutterStandardMethodCodec.sharedInstance() + handler?(codec.encode(FlutterMethodCall(methodName: "configure", arguments: args)), { _ in }) + } + func send(onChannel channel: String, message: Data?) { + guard let message else { return } + calls.append(FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message)) + } + func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { + send(onChannel: channel, message: message) + callback?(FlutterStandardMethodCodec.sharedInstance().encodeSuccessEnvelope(nil)) + } + func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection { + self.handler = handler + return 1 + } + func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) {} +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 0d9974d1f4..4fb91d069d 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; }; 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; }; 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; }; + CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8300022F50000100A17E01 /* IosMediaCarousel.swift */; }; AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */; }; 4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */; }; 4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */; }; @@ -121,6 +122,7 @@ 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = ""; }; 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = ""; }; 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = ""; }; + CA8300022F50000100A17E01 /* IosMediaCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosMediaCarousel.swift; sourceTree = ""; }; AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGeometry.swift; sourceTree = ""; }; 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceNotePackager.swift; sourceTree = ""; }; 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MP4Canonicalizer.swift; sourceTree = ""; }; @@ -291,6 +293,7 @@ 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, + CA8300022F50000100A17E01 /* IosMediaCarousel.swift */, AC7400022F50000100A17E01 /* ThemePaginationGeometry.swift */, 4A71C0202F4100100A17E01 /* VoiceNotePackager.swift */, 4A71C0222F4110100A17E01 /* MP4Canonicalizer.swift */, @@ -627,6 +630,7 @@ 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, + CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */, AC7400012F50000100A17E01 /* ThemePaginationGeometry.swift in Sources */, 4A71C01F2F4100100A17E01 /* VoiceNotePackager.swift in Sources */, 4A71C0212F4110100A17E01 /* MP4Canonicalizer.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 36405a405e..fb88d746b0 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -59,6 +59,10 @@ import os.log func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosMediaCarousel") { + registrar.register(IosMediaCarouselFactory(messenger: registrar.messenger()), withId: "buzz/ios_media_carousel") + } + let messenger = engineBridge.applicationRegistrar.messenger() huddleMediaPlugin = HuddleMediaPlugin(messenger: messenger) if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosNavigationBar") { diff --git a/mobile/ios/Runner/IosMediaCarousel.swift b/mobile/ios/Runner/IosMediaCarousel.swift new file mode 100644 index 0000000000..285a9edb32 --- /dev/null +++ b/mobile/ios/Runner/IosMediaCarousel.swift @@ -0,0 +1,202 @@ +import Flutter +import UIKit + +final class IosMediaCarouselFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + init(messenger: FlutterBinaryMessenger) { self.messenger = messenger } + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { + IosMediaCarousel(frame: frame, id: viewId, arguments: args, messenger: messenger) + } +} + +private final class CarouselPhotoCell: UICollectionViewCell { + let image = UIImageView() + let status = UILabel() + var request: String? + var cancelRequest: (() -> Void)? + func cancelImage() { + cancelRequest?() + cancelRequest = nil + request = nil + } + override init(frame: CGRect) { + super.init(frame: frame) + image.contentMode = .scaleAspectFill + contentView.clipsToBounds = true + contentView.layer.cornerCurve = .continuous + contentView.backgroundColor = .secondarySystemBackground + status.textAlignment = .center + status.numberOfLines = 2 + status.font = .preferredFont(forTextStyle: .caption1) + status.textColor = .secondaryLabel + contentView.addSubview(image) + contentView.addSubview(status) + isAccessibilityElement = true + accessibilityTraits = .button + } + required init?(coder: NSCoder) { nil } + override func layoutSubviews() { + super.layoutSubviews() + image.frame = contentView.bounds + status.frame = contentView.bounds.insetBy(dx: 12, dy: 12) + } + override func prepareForReuse() { + super.prepareForReuse() + cancelImage() + image.image = nil + status.text = nil + } +} + +/// A native collection view owns drag tracking, deceleration and snapping. +/// Thumbnail bytes come from Flutter's authenticated, community-scoped cache. +final class IosMediaCarousel: NSObject, FlutterPlatformView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + private let collection: UICollectionView + private let layout = UICollectionViewFlowLayout() + private let channel: FlutterMethodChannel + private var labels: [String] = [] + private var stride: CGFloat = 1 + private var gutter: CGFloat = 0 + private var gap: CGFloat = 8 + private var trailingPadding: CGFloat = 16 + private var radius: CGFloat = 16 + private var rtl = false + private var ready = false + private var applying = false + private var selected = 0 + + init(frame: CGRect, id: Int64, arguments: Any?, messenger: FlutterBinaryMessenger) { + layout.scrollDirection = .horizontal + collection = UICollectionView(frame: frame, collectionViewLayout: layout) + channel = FlutterMethodChannel(name: "buzz/ios_media_carousel/\(id)", binaryMessenger: messenger) + super.init() + collection.backgroundColor = .clear + collection.showsHorizontalScrollIndicator = false + collection.alwaysBounceHorizontal = true + collection.decelerationRate = .fast + collection.contentInsetAdjustmentBehavior = .never + collection.dataSource = self + collection.delegate = self + collection.register(CarouselPhotoCell.self, forCellWithReuseIdentifier: "photo") + apply(arguments as? [String: Any] ?? [:]) + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { result(nil); return } + if call.method == "imageFrame", let args = call.arguments as? [String: Any], + let id = args["id"] as? String { + for case let cell as CarouselPhotoCell in self.collection.visibleCells where cell.request == id { + let image = (args["bytes"] as? FlutterStandardTypedData).flatMap { UIImage(data: $0.data) } + cell.image.image = image + cell.status.text = image == nil ? "Image unavailable\nTap to open" : nil + } + result(nil) + return + } + guard call.method == "configure", let args = call.arguments as? [String: Any] else { + result(FlutterMethodNotImplemented) + return + } + let wasReady = self.ready + self.ready = true + self.apply(args) + if !wasReady { self.collection.reloadData() } + result(nil) + } + } + func view() -> UIView { collection } + + private func apply(_ args: [String: Any]) { + applying = true + defer { applying = false } + let oldTrailingPadding = trailingPadding + let oldStride = stride + let oldGutter = gutter + let oldRTL = rtl + let oldLabels = labels + labels = args["labels"] as? [String] ?? [] + stride = max(1, (args["width"] as? Double ?? 1) * 0.9) + gutter = args["gutter"] as? Double ?? 0 + gap = args["gap"] as? Double ?? 8 + trailingPadding = args["trailingPadding"] as? Double ?? 16 + radius = args["radius"] as? Double ?? 16 + rtl = args["rtl"] as? Bool ?? false + collection.overrideUserInterfaceStyle = args["brightness"] as? String == "dark" ? .dark : .light + // Reflect the strip and each cell, keeping logical indices in reading order. + collection.transform = rtl ? CGAffineTransform(scaleX: -1, y: 1) : .identity + collection.contentInset = UIEdgeInsets(top: 0, left: gutter, bottom: 0, right: gutter) + layout.minimumLineSpacing = gap + // The strip overflows the message gutter; restore that full inset at its end. + layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: trailingPadding) + layout.invalidateLayout() + for cell in collection.visibleCells { + cell.contentView.layer.cornerRadius = radius + } + if oldTrailingPadding != trailingPadding || oldStride != stride || oldGutter != gutter || oldLabels != labels || oldRTL != rtl { + selected = min(selected, max(0, labels.count - 1)) + for case let cell as CarouselPhotoCell in collection.visibleCells { cell.cancelImage() } + collection.reloadData() + collection.layoutIfNeeded() + collection.contentOffset.x = clampedOffset(CGFloat(selected) * stride - gutter) + } + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + labels.count + } + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + CGSize(width: max(1, stride - gap), height: max(1, collectionView.bounds.height)) + } + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photo", for: indexPath) as? CarouselPhotoCell else { + return UICollectionViewCell() + } + cell.transform = rtl ? CGAffineTransform(scaleX: -1, y: 1) : .identity + cell.contentView.layer.cornerRadius = radius + cell.accessibilityLabel = "Open \(labels[indexPath.item])" + cell.accessibilityValue = "\(indexPath.item + 1) of \(labels.count)" + cell.status.text = "Loading image" + return cell + } + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard ready, let cell = cell as? CarouselPhotoCell else { return } + cell.cancelImage() + let id = UUID().uuidString + cell.request = id + cell.cancelRequest = { [weak self] in + self?.channel.invokeMethod("cancelImage", arguments: id) + } + channel.invokeMethod("subscribeImage", arguments: ["id": id, "index": indexPath.item]) + } + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + (cell as? CarouselPhotoCell)?.cancelImage() + } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + channel.invokeMethod("open", arguments: indexPath.item) + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard ready, !applying, !labels.isEmpty else { return } + let offset = max(0, scrollView.contentOffset.x + gutter) + channel.invokeMethod("scroll", arguments: offset) + let index = min(labels.count - 1, max(0, Int((offset / stride).rounded()))) + if index != selected { + selected = index + channel.invokeMethod("page", arguments: index) + } + } + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + let page = ((targetContentOffset.pointee.x + gutter) / stride).rounded() + targetContentOffset.pointee.x = clampedOffset(page * stride - gutter) + } + private func clampedOffset(_ offset: CGFloat) -> CGFloat { + let maximum = max(-gutter, collection.contentSize.width - collection.bounds.width + gutter) + return min(maximum, max(-gutter, offset)) + } + deinit { + for case let cell as CarouselPhotoCell in collection.visibleCells { + if let id = cell.request { channel.invokeMethod("cancelImage", arguments: id) } + } + channel.setMethodCallHandler(nil) + } +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 2d60fc54d0..6e7e0d019e 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../shared/widgets/ios_media_carousel.dart'; import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:io'; import 'dart:math' as math; diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 5165be8de5..3225689484 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -103,98 +103,147 @@ class _MessageImageCarousel extends HookConsumerWidget { preloadProvider: previewProviders[index], ), ]; + final nativeGutter = math.max( + 0.0, + MediaQuery.sizeOf(context).width - carouselWidth, + ); final carousel = SizedBox( key: const ValueKey('message-media-carousel'), width: carouselWidth, height: messageMediaCarouselHeight, - child: _MessageCarouselViewport( - width: carouselWidth, - currentIndex: currentIndex.value, - itemCount: items.length, - onPageChanged: (index) { - if (index != currentIndex.value) { - unawaited(HapticFeedback.selectionClick()); - } - currentIndex.value = index; - }, - itemBuilder: (context, index) { - final item = items[index]; - return Padding( - key: ValueKey('message-media-carousel-page:${item.url}'), - padding: EdgeInsetsDirectional.only( - end: itemTrailingPaddings[index], - ), - child: Semantics( - button: true, - excludeSemantics: true, - label: 'Open ${item.semanticLabel}', - child: GestureDetector( - key: ValueKey('message-media-carousel-item:${item.url}'), - onTap: () => openImageViewer( + child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS + ? OverflowBox( + minWidth: carouselWidth + 2 * nativeGutter, + maxWidth: carouselWidth + 2 * nativeGutter, + child: IosMediaCarousel( + key: ValueKey((itemSignature, mediaAuth)), + providers: previewProviders, + labels: [for (final item in items) item.semanticLabel], + width: carouselWidth, + gutter: nativeGutter, + gap: Grid.half, + trailingPadding: Grid.gutter, + radius: + (MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md)) + .topLeft + .x, + onScroll: (offset) => MessageGalleryScrollNotification( + offset, + ).dispatch(context), + onPageChanged: (index) { + if (index != currentIndex.value) { + unawaited(HapticFeedback.selectionClick()); + } + currentIndex.value = index; + }, + onOpen: (index) => openImageViewer( context, - imageUrl: item.url, + imageUrl: items[index].url, heroTag: heroTags[index], - semanticLabel: item.semanticLabel, + semanticLabel: items[index].semanticLabel, previewDecodeWidth: previewDecodeWidths[index], - aspectRatio: item.aspectRatio, + aspectRatio: items[index].aspectRatio, galleryItems: viewerItems, initialIndex: index, onReply: onReply, onMore: onMore, ), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: - MessageAttachmentCorners.of(context) ?? - BorderRadius.circular(Radii.md), - border: Border.all( - color: context.colors.outlineVariant, - ), + ), + ) + : _MessageCarouselViewport( + width: carouselWidth, + currentIndex: currentIndex.value, + itemCount: items.length, + onPageChanged: (index) { + if (index != currentIndex.value) { + unawaited(HapticFeedback.selectionClick()); + } + currentIndex.value = index; + }, + itemBuilder: (context, index) { + final item = items[index]; + return Padding( + key: ValueKey( + 'message-media-carousel-page:${item.url}', ), - child: MediaViewerHero( - tag: heroTags[index], - child: ClipRRect( - borderRadius: - MessageAttachmentCorners.of(context) ?? - BorderRadius.circular(Radii.md), - child: MediaImage( - url: item.url, - decodeWidth: previewDecodeWidths[index], - fit: BoxFit.cover, + padding: EdgeInsetsDirectional.only( + end: itemTrailingPaddings[index], + ), + child: Semantics( + button: true, + excludeSemantics: true, + label: 'Open ${item.semanticLabel}', + child: GestureDetector( + key: ValueKey( + 'message-media-carousel-item:${item.url}', + ), + onTap: () => openImageViewer( + context, + imageUrl: item.url, + heroTag: heroTags[index], semanticLabel: item.semanticLabel, - frameBuilder: - (context, child, frame, synchronous) => - frame != null || synchronous - ? child - : const MediaLoadingPlaceholder( - label: 'Loading image', + previewDecodeWidth: previewDecodeWidths[index], + aspectRatio: item.aspectRatio, + galleryItems: viewerItems, + initialIndex: index, + onReply: onReply, + onMore: onMore, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant, + ), + ), + child: MediaViewerHero( + tag: heroTags[index], + child: ClipRRect( + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), + child: MediaImage( + url: item.url, + decodeWidth: previewDecodeWidths[index], + fit: BoxFit.cover, + semanticLabel: item.semanticLabel, + frameBuilder: + (context, child, frame, synchronous) => + frame != null || synchronous + ? child + : const MediaLoadingPlaceholder( + label: 'Loading image', + ), + errorBuilder: (_, _, _) => + const _MediaPreviewFallback( + icon: BuzzIcons.imageOff, + label: 'Image unavailable', ), - errorBuilder: (_, _, _) => - const _MediaPreviewFallback( - icon: BuzzIcons.imageOff, - label: 'Image unavailable', ), + ), + ), ), ), ), - ), - ), + ); + }, ), - ); - }, - ), ); final carouselSurface = Stack( clipBehavior: Clip.none, children: [ carousel, - _MessageGalleryPrecache( - providers: previewProviders, - focusedIndex: currentIndex.value, - ), + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) + _MessageGalleryPrecache( + providers: previewProviders, + focusedIndex: currentIndex.value, + ), ], ); diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 0ab5b5aa20..4b5aa5ccb8 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -45,12 +45,17 @@ class MediaImageProvider extends ImageProvider { /// Excluded from equality: transport, not identity. final http.Client client; + /// Deadline for the complete response, including a stalled response body. + /// Aborts the request without closing the shared client. + final Duration requestTimeout; + const MediaImageProvider({ required this.url, required this.auth, required this.client, this.onBytesLoaded, this.scale = 1.0, + this.requestTimeout = const Duration(seconds: 30), }); static const _defaultCooldown = Duration(seconds: 30); @@ -97,7 +102,18 @@ class MediaImageProvider extends ImageProvider { final uri = Uri.parse(url); final http.Response response; try { - response = await client.get(uri, headers: auth.headersFor(url)); + final abort = Completer(); + final deadline = Timer(requestTimeout, abort.complete); + try { + final request = http.AbortableRequest( + 'GET', + uri, + abortTrigger: abort.future, + )..headers.addAll(auth.headersFor(url)); + response = await http.Response.fromStream(await client.send(request)); + } finally { + deadline.cancel(); + } } catch (_) { _cooldownUntil[url] = debugNow().add(_defaultCooldown); rethrow; diff --git a/mobile/lib/shared/widgets/ios_media_carousel.dart b/mobile/lib/shared/widgets/ios_media_carousel.dart new file mode 100644 index 0000000000..19f125a364 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_media_carousel.dart @@ -0,0 +1,279 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// UIKit's horizontally scrolling photo strip, using the app's image providers. +/// Authentication and image-cache isolation remain owned by Flutter. +class IosMediaCarousel extends HookWidget { + const IosMediaCarousel({ + super.key, + required this.providers, + required this.labels, + required this.width, + required this.gutter, + required this.gap, + required this.trailingPadding, + required this.radius, + required this.onOpen, + required this.onScroll, + required this.onPageChanged, + }); + + static const viewType = 'buzz/ios_media_carousel'; + final List> providers; + final List labels; + final double width; + final double gutter; + final double gap; + final double trailingPadding; + final double radius; + final ValueChanged onOpen; + final ValueChanged onScroll; + final ValueChanged onPageChanged; + + @override + Widget build(BuildContext context) { + final channel = useState(null); + final latest = useRef(this)..value = this; + final brightness = Theme.of(context).brightness.name; + final labelSignature = Object.hashAll(labels); + final rtl = Directionality.of(context) == TextDirection.rtl; + final configuration = { + 'labels': labels, + 'width': width, + 'gutter': gutter, + 'gap': gap, + 'trailingPadding': trailingPadding, + 'radius': radius, + 'brightness': brightness, + 'rtl': rtl, + }; + useEffect(() { + final bridge = channel.value; + if (bridge == null) return null; + final images = _CarouselImages(bridge); + channel.value!.setMethodCallHandler((call) async { + final widget = latest.value; + final argument = call.arguments; + if (call.method == 'cancelImage' && argument is String) { + images.cancel(argument); + } else if (call.method == 'subscribeImage' && argument is Map) { + final id = argument['id']; + final index = argument['index']; + if (id is String && + index is int && + index >= 0 && + index < widget.providers.length) { + images.subscribe( + id, + widget.providers[index], + createLocalImageConfiguration(context), + ); + } + } else if (call.method == 'scroll' && argument is num) { + widget.onScroll(argument.toDouble()); + } else if (argument is int && + argument >= 0 && + argument < widget.providers.length) { + if (call.method == 'open') widget.onOpen(argument); + if (call.method == 'page') widget.onPageChanged(argument); + } + return null; + }); + // UIKit requests visible thumbnails only after the Dart handler exists. + unawaited(bridge.invokeMethod('configure', configuration)); + return () { + images.dispose(); + bridge.setMethodCallHandler(null); + }; + }, [channel.value]); + useEffect( + () { + final bridge = channel.value; + if (bridge != null) { + unawaited(bridge.invokeMethod('configure', configuration)); + } + return null; + }, + [ + width, + gutter, + gap, + trailingPadding, + radius, + brightness, + rtl, + labelSignature, + ], + ); + + return UiKitView( + viewType: viewType, + creationParams: configuration, + creationParamsCodec: const StandardMessageCodec(), + gestureRecognizers: { + Factory( + HorizontalDragGestureRecognizer.new, + ), + }, + onPlatformViewCreated: (id) { + channel.value = MethodChannel('$viewType/$id'); + }, + ); + } +} + +// Keep native subscriptions and unresolved provider loads bounded together. +// A cancelled load keeps its slot until its first frame/error. MediaImageProvider +// aborts stalled HTTP requests at its deadline, allowing queued cells to progress +// without releasing slots while their transports are still running. +class _CarouselImages { + _CarouselImages(this.channel); + final MethodChannel channel; + final _queued = {}; + final _running = {}; + bool _disposed = false; + bool _draining = false; + + void subscribe( + String id, + ImageProvider provider, + ImageConfiguration config, + ) { + if (_disposed || _running.containsKey(id) || _queued.containsKey(id)) { + return; + } + _queued[id] = _CarouselImage(id, provider, config); + _drain(); + } + + void cancel(String id) { + _queued.remove(id); + final image = _running[id]; + if (image == null) return; + image.cancelled = true; + image.nextFrame?.dispose(); + image.nextFrame = null; + if (image.receivedFrame) { + image.detach(); + if (!image.encoding) _finish(image); + } + } + + void _drain() { + if (_draining || _disposed) return; + _draining = true; + try { + while (_running.length < 4 && _queued.isNotEmpty) { + final image = _queued.remove(_queued.keys.first)!; + _running[image.id] = image; + image.stream = image.provider.resolve(image.config); + image.listener = ImageStreamListener( + (frame, _) { + image.receivedFrame = true; + if (image.cancelled || _disposed) { + frame.dispose(); + _finish(image); + return; + } + image.nextFrame?.dispose(); + image.nextFrame = frame; + if (!image.encoding) unawaited(_sendFrames(image)); + }, + onError: (Object _, StackTrace? _) { + image.receivedFrame = true; + if (!image.cancelled && !_disposed) { + unawaited(_send(image.id, null)); + } + image.cancelled = true; + image.detach(); + if (!image.encoding) _finish(image); + }, + ); + image.stream!.addListener(image.listener!); + } + } finally { + _draining = false; + } + } + + Future _sendFrames(_CarouselImage image) async { + image.encoding = true; + try { + while (!_disposed && !image.cancelled && image.nextFrame != null) { + final frame = image.nextFrame!; + image.nextFrame = null; + try { + final bytes = await frame.image.toByteData( + format: ui.ImageByteFormat.png, + ); + if (!_disposed && !image.cancelled) { + await _send(image.id, bytes?.buffer.asUint8List()); + } + } finally { + frame.dispose(); + } + } + } catch (_) { + if (!_disposed && !image.cancelled) await _send(image.id, null); + image.cancelled = true; + } finally { + image.encoding = false; + if (_disposed || image.cancelled) _finish(image); + } + } + + Future _send(String id, Uint8List? bytes) async { + try { + await channel.invokeMethod('imageFrame', { + 'id': id, + 'bytes': bytes, + }); + } on PlatformException { + // The native view may disappear while a frame crosses the bridge. + } on MissingPluginException { + // Disposal can remove the channel before an in-flight frame arrives. + } + } + + void _finish(_CarouselImage image) { + image.detach(); + image.nextFrame?.dispose(); + image.nextFrame = null; + _running.remove(image.id); + _drain(); + } + + void dispose() { + _disposed = true; + _queued.clear(); + for (final image in _running.values.toList()) { + image.cancelled = true; + _finish(image); + } + } +} + +class _CarouselImage { + _CarouselImage(this.id, this.provider, this.config); + final String id; + final ImageProvider provider; + final ImageConfiguration config; + ImageStream? stream; + ImageStreamListener? listener; + ImageInfo? nextFrame; + bool cancelled = false; + bool receivedFrame = false; + bool encoding = false; + + void detach() { + final listener = this.listener; + if (listener != null) stream?.removeListener(listener); + this.listener = null; + } +} diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index f5a7dac352..79ec509e72 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -241,6 +241,9 @@ void main() { await tester.pumpAndSettle(); expect(container.read(profileAvatarHandoffProvider), isNull); + // Finish the deliberately paused transport so its deadline is cancelled. + animationResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.pump(); }); testWidgets('keeps the avatar unbadged while showing the current status', ( tester, diff --git a/mobile/test/shared/widgets/ios_media_carousel_test.dart b/mobile/test/shared/widgets/ios_media_carousel_test.dart new file mode 100644 index 0000000000..6e5a2fdabb --- /dev/null +++ b/mobile/test/shared/widgets/ios_media_carousel_test.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:buzz/shared/widgets/ios_media_carousel.dart'; +import 'package:buzz/shared/relay/media_auth.dart'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final stallBody in [false, true]) { + testWidgets( + 'stalled ${stallBody ? "bodies" : "headers"} abort before queued photo loads', + (tester) async { + final client = (await tester.runAsync(() async => _TrackingClient()))!; + final server = await tester.runAsync( + () => HttpServer.bind(InternetAddress.loopbackIPv4, 0), + ); + addTearDown(() async { + client.close(); + await server!.close(force: true); + MediaImageProvider.debugResetCooldowns(); + }); + final base = 'http://127.0.0.1:${server!.port}'; + final auth = MediaGetAuthService(baseUrl: base, nsec: null); + await tester.runAsync(() async { + server.listen((request) { + if (request.uri.path == '/4') { + request.response.statusCode = 404; + unawaited(request.response.close()); + } else if (stallBody) { + request.response.contentLength = 100; + request.response.add([0]); + unawaited(request.response.flush()); + } + // The first four never finish: only client-side abort can release them. + }); + }); + final harness = await _mount( + tester, + List.generate( + 5, + (index) => MediaImageProvider( + url: '$base/$index', + auth: auth, + client: client, + requestTimeout: const Duration(seconds: 1), + ), + ), + ); + await tester.runAsync(() async { + for (var index = 0; index < 4; index++) { + await harness.send('subscribeImage', { + 'id': '$index', + 'index': index, + }); + await harness.send('cancelImage', '$index'); + } + await harness.send('subscribeImage', {'id': 'latest', 'index': 4}); + expect(client.started, 4); + await harness.waitForFrames(1); + await client.retired.future.timeout(const Duration(seconds: 5)); + }); + expect(client.started, 5); + expect(client.peak, 4); + expect(client.aborted, 4); + expect(client.active, 0); + expect(harness.frames, [ + {'id': 'latest', 'bytes': null}, + ]); + await tester.pumpWidget(const SizedBox()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + + testWidgets( + 'native callbacks and disposal release subscribed images', + (tester) async { + final provider = _TestImage(); + final harness = await _mount(tester, [provider]); + await harness.send('open', 0); + await harness.send('open', 2); + await harness.send('page', 0); + await harness.send('scroll', 21.5); + expect(harness.opened, [0]); + expect(harness.pages, [0]); + expect(harness.offsets, [21.5]); + await harness.send('subscribeImage', {'id': 'a', 'index': 0}); + expect(provider.completer.listeners, hasLength(1)); + await tester.pumpWidget(const SizedBox()); + expect(provider.completer.listeners, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'multi-image native gallery forwards continuing animation frames', + (tester) async { + final animated = _TestImage(); + final still = _TestImage(); + final harness = await _mount(tester, [animated, still]); + await harness.send('subscribeImage', {'id': 'animated', 'index': 0}); + await harness.send('subscribeImage', {'id': 'still', 'index': 1}); + final decoded = await tester.runAsync(() async { + // Two 1x1 GIF frames with different palette indices: red, then blue. + final codec = await ui.instantiateImageCodec( + base64Decode( + 'R0lGODlhAQABAIAAAP8AAAAA/yH5BAAKAAAALAAAAAABAAEAAAICRAEAIfkEAAoAAAAsAAAAAAEAAQAAAgJMAQA7', + ), + ); + try { + expect(codec.frameCount, 2); + return [ + (await codec.getNextFrame()).image, + (await codec.getNextFrame()).image, + ]; + } finally { + codec.dispose(); + } + }); + await tester.runAsync(() async { + animated.completer.emit(decoded![0]); + await harness.waitForFrames(1); + animated.completer.emit(decoded[1]); + await harness.waitForFrames(2); + }); + expect(harness.frames.map((f) => f['id']), ['animated', 'animated']); + expect( + harness.frames[0]['bytes'], + isNot(orderedEquals(harness.frames[1]['bytes'] as Uint8List)), + ); + await harness.send('cancelImage', 'animated'); + expect(animated.completer.listeners, isEmpty); + final count = harness.frames.length; + final green = await tester.runAsync(() => _frame(Colors.green)); + animated.completer.emit(green!); + await tester.pump(); + expect(harness.frames, hasLength(count)); + await tester.pumpWidget(const SizedBox()); + expect(still.completer.listeners, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'rapid reuse bounds unresolved loads and drops cancelled queued requests', + (tester) async { + final providers = List.generate(20, (_) => _TestImage()); + final harness = await _mount(tester, providers); + for (var index = 0; index < 20; index++) { + await harness.send('subscribeImage', {'id': '$index', 'index': index}); + if (index < 19) await harness.send('cancelImage', '$index'); + } + expect(providers.where((p) => p.resolutions > 0), hasLength(4)); + expect( + providers.skip(4).take(15).every((p) => p.completer.listeners.isEmpty), + isTrue, + ); + // Retired pending providers retain a bounded completion observer because + // transport termination must precede releasing that slot. + final image = await tester.runAsync(() => _frame(Colors.red)); + providers[0].completer.emit(image!); + await tester.pump(); + expect(providers[0].completer.listeners, isEmpty); + expect(providers[19].resolutions, 1); + expect( + harness.frames, + isEmpty, + reason: 'Cancelled loads must never reach UIKit', + ); + await tester.pumpWidget(const SizedBox()); + expect(providers.every((p) => p.completer.listeners.isEmpty), isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} + +Future _frame(Color color) async { + final recorder = ui.PictureRecorder(); + Canvas( + recorder, + ).drawRect(const Rect.fromLTWH(0, 0, 2, 2), Paint()..color = color); + final picture = recorder.endRecording(); + try { + return await picture.toImage(2, 2); + } finally { + picture.dispose(); + } +} + +Future<_Harness> _mount( + WidgetTester tester, + List> providers, +) async { + final harness = _Harness(tester); + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + harness.channel = MethodChannel('${IosMediaCarousel.viewType}/$id'); + messenger.setMockMethodCallHandler(harness.channel, (call) async { + if (call.method == 'imageFrame') { + harness.frames.add(Map.from(call.arguments as Map)); + harness.changed?.complete(); + harness.changed = null; + } + return null; + }); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + messenger.setMockMethodCallHandler(harness.channel, null); + }); + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 300, + height: 200, + child: IosMediaCarousel( + providers: providers, + labels: List.generate(providers.length, (i) => 'Photo $i'), + width: 300, + gutter: 20, + gap: 4, + trailingPadding: 20, + radius: 16, + onOpen: harness.opened.add, + onScroll: harness.offsets.add, + onPageChanged: harness.pages.add, + ), + ), + ), + ); + await tester.pump(); + return harness; +} + +class _Harness { + _Harness(this.tester); + final WidgetTester tester; + late MethodChannel channel; + final opened = []; + final pages = []; + final offsets = []; + final frames = >[]; + Completer? changed; + Future waitForFrames(int count) async { + while (frames.length < count) { + changed = Completer(); + await changed!.future.timeout(const Duration(seconds: 5)); + } + } + + Future send(String method, Object argument) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall(method, argument)), + (_) {}, + ); + } +} + +class _TestImage extends ImageProvider<_TestImage> { + _TestImage() { + final handle = completer.keepAlive(); + addTearDown(handle.dispose); + } + final completer = _TestCompleter(); + int resolutions = 0; + @override + Future<_TestImage> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + @override + void resolveStreamForKey( + ImageConfiguration configuration, + ImageStream stream, + _TestImage key, + ImageErrorListener handleError, + ) { + resolutions++; + stream.setCompleter(completer); + } +} + +class _TestCompleter extends ImageStreamCompleter { + final listeners = {}; + @override + void addListener(ImageStreamListener listener) { + listeners.add(listener); + super.addListener(listener); + } + + @override + void removeListener(ImageStreamListener listener) { + listeners.remove(listener); + super.removeListener(listener); + } + + void emit(ui.Image image) => setImage(ImageInfo(image: image)); +} + +// Real IO transport: count through body completion, not just response headers. +class _TrackingClient extends http.BaseClient { + final inner = IOClient(_RealHttpOverrides().createHttpClient(null)); + int active = 0, peak = 0, started = 0, aborted = 0; + final retired = Completer(); + void recordAbort() { + aborted++; + if (aborted == 4) retired.complete(); + } + + @override + Future send(http.BaseRequest request) async { + started++; + active++; + if (active > peak) peak = active; + try { + final response = await inner.send(request); + return http.StreamedResponse(_body(response.stream), response.statusCode); + } catch (error) { + active--; + if (error is http.RequestAbortedException) recordAbort(); + rethrow; + } + } + + Stream> _body(Stream> stream) async* { + try { + await for (final chunk in stream) { + yield chunk; + } + } catch (error) { + if (error is http.RequestAbortedException) recordAbort(); + rethrow; + } finally { + active--; + } + } + + @override + void close() => inner.close(); +} + +class _RealHttpOverrides extends HttpOverrides {} diff --git a/scripts/test-mobile-carousel.sh b/scripts/test-mobile-carousel.sh new file mode 100644 index 0000000000..3aa2c5f3a3 --- /dev/null +++ b/scripts/test-mobile-carousel.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Test the actual UIKit carousel and Flutter bridge on a booted iOS simulator. +# Usage: /bin/bash scripts/test-mobile-carousel.sh +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +device=${1:?Pass a booted iOS simulator UDID} +flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig") +frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" +fixture=$(mktemp -d) +trap 'rm -r "$fixture"' EXIT +xcrun --sdk iphonesimulator swiftc \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + -target "$(uname -m)-apple-ios15.0-simulator" \ + -F "$frameworks" -framework Flutter \ + -Xlinker -rpath -Xlinker "$frameworks" \ + "$repo_root/mobile/ios/Runner/IosMediaCarousel.swift" \ + "$repo_root/mobile/ios/NativeControlTests/IosMediaCarouselTests.swift" \ + -o "$fixture/carousel-tests" +xcrun simctl spawn "$device" "$fixture/carousel-tests"