Files
buzz/mobile/ios/Runner/AppDelegate.swift

1046 lines
33 KiB
Swift

import AVFoundation
import BuzzPushKit
import DeclaredAgeRange
import Flutter
import UIKit
import UserNotifications
import os.log
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var nativeMessagePresentationCoordinator: NativeMessagePresentationCoordinator?
private var mediaUploadChannel: FlutterMethodChannel?
private var pushChannel: FlutterMethodChannel?
private let apnsRegistrationBuffer = APNsRegistrationBuffer()
private let pushNavigationBuffer = BuzzPushNavigationBuffer()
private var apnsDeviceToken: Data?
private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore(
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
)
private var enrollmentTask: Task<Void, Never>?
var appGroupIdentifier: String? {
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
}
private var pushKeychainAccessGroup: String? {
Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
}
private lazy var pushSnapshotBridge = BuzzPushSnapshotBridge(
appGroupIdentifier: appGroupIdentifier,
endpointGrantStore: endpointGrantStore,
keychainAccessGroup: pushKeychainAccessGroup
)
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var ageSignalChannel: FlutterMethodChannel?
var requestPlatformAgeSignal: @MainActor (UIViewController) async throws -> [String: Any] =
AppDelegate.platformAgeSignal
private var ageSignalTask: Task<Void, Never>?
private var ageSignalRequestID: UUID?
private var ageSignalResult: FlutterResult?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
private var huddleMediaPlugin: HuddleMediaPlugin?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Age checking and notification restoration run asynchronously from
// Flutter. No age-related storage or platform request may delay launch.
UNUserNotificationCenter.current().delegate = self
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let messenger = engineBridge.applicationRegistrar.messenger()
huddleMediaPlugin = HuddleMediaPlugin(messenger: messenger)
mediaUploadChannel = FlutterMethodChannel(
name: "buzz/media_upload",
binaryMessenger: messenger
)
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleMediaUploadMethodCall(call, result: result)
}
pushChannel = FlutterMethodChannel(
name: "buzz/push",
binaryMessenger: messenger
)
pushChannel?.setMethodCallHandler { [weak self] call, result in
self?.handlePushMethodCall(call, result: result)
}
apnsRegistrationBuffer.attach { [weak self] update in
self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments)
}
qrScannerChannel = FlutterMethodChannel(
name: "buzz/qr_scanner",
binaryMessenger: messenger
)
qrScannerChannel?.setMethodCallHandler { call, result in
Self.handleQrScannerMethodCall(call, result: result)
}
inlinePhotoPickerSupportChannel = FlutterMethodChannel(
name: "buzz/inline_photo_picker",
binaryMessenger: messenger
)
inlinePhotoPickerSupportChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
if #available(iOS 17.0, *) {
result(true)
} else {
result(false)
}
}
ageSignalChannel = FlutterMethodChannel(
name: "buzz/age_signal",
binaryMessenger: messenger
)
let ageSignalRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzAgeSignal"
)
ageSignalChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleAgeSignalMethodCall(
call,
viewController: ageSignalRegistrar?.viewController,
result: result
)
}
if let inlinePhotoPickerRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzInlinePhotoPicker"
) {
inlinePhotoPickerRegistrar.register(
InlinePhotoPickerFactory(
messenger: messenger,
parentViewController: inlinePhotoPickerRegistrar.viewController
),
withId: "buzz/inline_photo_picker"
)
}
if let concentricSheetRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzConcentricSheetSurface"
) {
concentricSheetRegistrar.register(
ConcentricSheetSurfaceFactory(messenger: messenger),
withId: "buzz/concentric_sheet_surface"
)
concentricSheetSurfaceChannel = FlutterMethodChannel(
name: "buzz/concentric_sheet_surface",
binaryMessenger: messenger
)
concentricSheetSurfaceChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
if #available(iOS 26.0, *) {
result(true)
} else {
result(false)
}
}
}
if let jumpToLatestGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzJumpToLatestGlassButton"
) {
jumpToLatestGlassRegistrar.register(
JumpToLatestGlassButtonFactory(messenger: messenger),
withId: "buzz/jump_to_latest_glass"
)
}
if let navigationGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNavigationGlassButton"
) {
navigationGlassRegistrar.register(
NavigationGlassButtonFactory(messenger: messenger),
withId: "buzz/navigation_glass"
)
}
if let segmentedControlRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeSegmentedControl"
) {
segmentedControlRegistrar.register(
NativeSegmentedControlFactory(messenger: messenger),
withId: "buzz/native_segmented_control"
)
}
if let skinToneRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeSkinToneControl"
) {
skinToneRegistrar.register(
NativeSkinToneControlFactory(messenger: messenger),
withId: "buzz/native_skin_tone_control"
)
}
if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzStickyDateGlassHeader"
) {
stickyDateGlassRegistrar.register(
StickyDateGlassHeaderFactory(messenger: messenger),
withId: "buzz/sticky_date_glass"
)
}
if let themePaginationGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzThemePaginationGlassControl"
) {
themePaginationGlassRegistrar.register(
ThemePaginationGlassControlFactory(messenger: messenger),
withId: "buzz/theme_pagination_glass"
)
}
nativeMessagePresentationCoordinator = NativeMessagePresentationCoordinator(
messenger: messenger,
parentViewController: engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeMessagePresentation"
)?.viewController
)
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
nativeAttachmentPopoverCoordinator = NativeAttachmentPopoverCoordinator(
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
let nativeEmojiPickerRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeEmojiPicker"
)
nativeEmojiPickerCoordinator = NativeEmojiPickerCoordinator(
messenger: messenger,
parentViewController: nativeEmojiPickerRegistrar?.viewController
)
let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeProfileTextEditor"
)
nativeProfileTextEditorCoordinator = NativeProfileTextEditorCoordinator(
messenger: messenger,
parentViewController: nativeProfileTextEditorRegistrar?.viewController
)
if #available(iOS 16.0, *),
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeMessageActionSurface"
)
{
nativeMessageActionsRegistrar.register(
NativeMessageActionSurfaceFactory(messenger: messenger),
withId: "buzz/native_message_action_surface"
)
nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
name: "buzz/native_message_action_surface",
binaryMessenger: messenger
)
nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
result(true)
}
}
}
func handleAgeSignalMethodCall(
_ call: FlutterMethodCall,
viewController: UIViewController?,
result: @escaping FlutterResult
) {
// iOS can retire the request in process. The generation fence prevents
// a late result from the cancelled task from completing a fresh request.
if call.method == "cancelAgeSignalRequest" || call.method == "restartForAgeSignal" {
cancelAgeSignalRequest()
result(true)
return
}
guard call.method == "requestAgeSignal" else {
result(FlutterMethodNotImplemented)
return
}
guard #available(iOS 26.0, *) else {
result(Self.noAgeSignalResponse)
return
}
guard let viewController else {
result(
FlutterError(
code: "age_signal_unavailable",
message: "The age signal presenter is unavailable.",
details: nil
)
)
return
}
let requestID = UUID()
ageSignalRequestID = requestID
ageSignalResult = result
let request = requestPlatformAgeSignal
ageSignalTask = Task { @MainActor [weak self] in
do {
let payload = try await request(viewController)
self?.completeAgeSignalRequest(requestID, value: payload)
} catch {
self?.completeAgeSignalRequest(
requestID,
value:
FlutterError(
code: "age_signal_unavailable",
message: "The age signal request failed.",
details: String(describing: type(of: error))
)
)
}
}
}
@MainActor
private static func platformAgeSignal(_ viewController: UIViewController) async throws -> [String: Any] {
guard #available(iOS 26.0, *) else { return noAgeSignalResponse }
let response = try await AgeRangeService.shared.requestAgeRange(ageGates: 18, in: viewController)
switch response {
case .declinedSharing:
return noAgeSignalResponse
case .sharing(let range):
return BuzzAgeSignalPayload.sharing(
exclusiveUpperBound: range.upperBound, lowerBound: range.lowerBound)
@unknown default:
throw NSError(domain: "BuzzAgeSignal", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Unsupported age signal response"])
}
}
private func completeAgeSignalRequest(_ requestID: UUID, value: Any?) {
guard ageSignalRequestID == requestID, let result = ageSignalResult else { return }
ageSignalRequestID = nil
ageSignalResult = nil
ageSignalTask = nil
result(value)
}
private func cancelAgeSignalRequest() {
let result = ageSignalResult
ageSignalRequestID = nil
ageSignalResult = nil
ageSignalTask?.cancel()
ageSignalTask = nil
result?(
FlutterError(
code: "age_signal_cancelled",
message: "The age signal request was cancelled.",
details: nil
)
)
}
private static let noAgeSignalResponse: [String: Any] = [
"status": "noSignal",
"ageUpper": NSNull(),
]
private static func handleQrScannerMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "usesDynamicIslandQrScannerPortal":
result(
UIDevice.current.userInterfaceIdiom == .phone
&& usesDynamicIslandQrScannerPortal(
safeAreaTopInset: activeWindowSafeAreaTopInset()
)
)
case "setDynamicIslandScannerStatusBarHidden":
guard let hidden = call.arguments as? Bool else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected a Bool status-bar visibility value.",
details: nil
)
)
return
}
UIApplication.shared.setStatusBarHidden(hidden, with: .fade)
result(nil)
case "performDynamicIslandQrScanSuccessHaptic":
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(.success)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
static func usesDynamicIslandQrScannerPortal(
safeAreaTopInset: CGFloat
) -> Bool {
safeAreaTopInset > 50
}
private static func activeWindowSafeAreaTopInset() -> CGFloat {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.safeAreaInsets.top ?? 0
}
override func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
apnsDeviceToken = deviceToken
apnsRegistrationBuffer.recordToken(deviceToken)
}
override func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
apnsRegistrationBuffer.recordError(error.localizedDescription)
}
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo,
onTarget: { target in
pushNavigationBuffer.record(target)
deliverPushNavigationTarget(target)
},
forwardToFlutter: { pluginCompletion in
self.forwardPushNotificationResponseToFlutter(
center,
response: response,
completion: pluginCompletion
)
},
completion: completionHandler
)
}
private func forwardPushNotificationResponseToFlutter(
_ center: UNUserNotificationCenter,
response: UNNotificationResponse,
completion: @escaping () -> Void
) {
super.userNotificationCenter(
center,
didReceive: response,
withCompletionHandler: completion
)
}
private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) {
pushChannel?.invokeMethod(
"notificationOpened",
arguments: target.flutterArguments
) { [weak self] result in
guard result as? String == "handled" else { return }
self?.pushNavigationBuffer.remove(ifMatching: target)
}
}
private func handlePushMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
if pushSnapshotBridge.handle(call, result: result) {
return
}
switch call.method {
case "startRegistration":
startPushRegistration(result: result)
case "takePendingNotificationResponse":
result(pushNavigationBuffer.take()?.flutterArguments)
case "notificationAuthorizationStatus":
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
result(Self.pushAuthorizationStatusName(settings.authorizationStatus))
}
}
case "openNotificationSettings":
openNotificationSettings(result: result)
case "endpointGrants":
do {
guard let arguments = call.arguments as? [String: Any],
let gatewayText = arguments["gatewayUrl"] as? String,
let gatewayURL = URL(string: gatewayText)
else { throw BuzzDevPushEnrollmentError.invalidGatewayURL }
let driver = try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayURL,
store: endpointGrantStore,
appAttestKeychainAccessGroup: pushKeychainAccessGroup
)
result(try driver.endpointGrants().map(\.flutterArguments))
} catch {
result(
FlutterError(
code: "endpoint_grant_read_failed",
message: "Unable to read persisted push endpoint grants.",
details: error.localizedDescription
)
)
}
case "enrollPush":
handleDevPushEnrollment(call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
static func pushAuthorizationStatusName(_ status: UNAuthorizationStatus) -> String {
switch status {
case .notDetermined:
return "notDetermined"
case .denied:
return "denied"
case .authorized:
return "authorized"
case .provisional:
return "provisional"
case .ephemeral:
return "ephemeral"
@unknown default:
return "unknown"
}
}
private func openNotificationSettings(result: @escaping FlutterResult) {
let settingsURLString: String
if #available(iOS 16.0, *) {
settingsURLString = UIApplication.openNotificationSettingsURLString
} else {
settingsURLString = UIApplication.openSettingsURLString
}
guard let url = URL(string: settingsURLString) else {
result(false)
return
}
UIApplication.shared.open(url, options: [:]) { opened in
DispatchQueue.main.async {
result(opened)
}
}
}
private func startPushRegistration(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) {
_, error in
if let error {
os_log(
"Buzz notification authorization request failed: %{public}@",
type: .error,
error.localizedDescription
)
}
}
// APNs token registration is independent from display authorization. A
// denied or failed prompt must not prevent gateway enrollment and leases.
UIApplication.shared.registerForRemoteNotifications()
result(nil)
}
private func handleDevPushEnrollment(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
guard enrollmentTask == nil else {
result(
FlutterError(
code: "enrollment_in_progress",
message: "Development push enrollment is already running.",
details: nil
)
)
return
}
guard let deviceToken = apnsDeviceToken else {
result(
FlutterError(
code: "missing_apns_token",
message: "APNs has not supplied a device token.",
details: nil
)
)
return
}
guard !deviceToken.isEmpty else {
result(
FlutterError(
code: "invalid_apns_token",
message: "APNs supplied an empty device token.",
details: nil
)
)
return
}
guard let arguments = call.arguments as? [String: Any],
let relayText = arguments["relayUrl"] as? String,
let relayURL = URL(string: relayText),
let gatewayText = arguments["gatewayUrl"] as? String,
let gatewayURL = URL(string: gatewayText)
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Development push enrollment requires relayUrl and gatewayUrl.",
details: nil
)
)
return
}
do {
let driver = try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayURL,
store: endpointGrantStore,
appAttestKeychainAccessGroup: Bundle.main.object(
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
) as? String
)
enrollmentTask = Task { [weak self] in
defer { self?.enrollmentTask = nil }
do {
let record = try await driver.enroll(
deviceToken: deviceToken,
relayURL: relayURL
)
await MainActor.run { result(record.flutterArguments) }
} catch {
await MainActor.run {
result(
FlutterError(
code: "dev_enrollment_failed",
message: "Development push enrollment failed.",
details: error.localizedDescription
)
)
}
}
}
} catch {
result(
FlutterError(
code: "dev_enrollment_configuration_failed",
message: "Development push enrollment is not configured.",
details: error.localizedDescription
)
)
}
}
private func handleMediaUploadMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "sanitizeImageForUpload":
guard
let arguments = call.arguments as? [String: Any],
let typedData = arguments["bytes"] as? FlutterStandardTypedData,
let mimeType = arguments["mimeType"] as? String
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected image bytes and mime type.",
details: nil
)
)
return
}
guard let image = UIImage(data: typedData.data) else {
result(
FlutterError(
code: "sanitize_failed",
message: "Unable to decode picked image.",
details: nil
)
)
return
}
do {
guard let sanitizedData = try MediaSanitizer.sanitizeImage(image, mimeType: mimeType) else {
result(
FlutterError(
code: "sanitize_failed",
message: "Unable to sanitize picked image.",
details: mimeType
)
)
return
}
result(FlutterStandardTypedData(bytes: sanitizedData))
} catch {
result(
FlutterError(
code: "sanitize_failed",
message: "Unable to sanitize picked image.",
details: mimeType
)
)
}
case "transcodeImageToJpeg":
guard let typedData = call.arguments as? FlutterStandardTypedData else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected raw image bytes.",
details: nil
)
)
return
}
guard let image = UIImage(data: typedData.data) else {
result(
FlutterError(
code: "transcode_failed",
message: "Unable to convert picked image to JPEG.",
details: nil
)
)
return
}
do {
guard let jpegData = try MediaSanitizer.encodeJpeg(image) else {
result(
FlutterError(
code: "transcode_failed",
message: "Unable to convert picked image to JPEG.",
details: nil
)
)
return
}
result(FlutterStandardTypedData(bytes: jpegData))
} catch {
result(
FlutterError(
code: "transcode_failed",
message: "Unable to convert picked image to JPEG.",
details: nil
)
)
}
case "transcodeVideoToMp4":
guard let sourcePath = call.arguments as? String else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected source file path as String.",
details: nil
)
)
return
}
transcodeVideoToMp4(sourcePath: sourcePath, result: result)
case "packageVoiceNoteForUpload":
guard let sourcePath = call.arguments as? String else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected source file path as String.",
details: nil
)
)
return
}
VoiceNotePackager.package(sourcePath: sourcePath, result: result)
case "generateVideoPoster":
guard let sourcePath = call.arguments as? String else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected source file path as String.",
details: nil
)
)
return
}
generateVideoPoster(sourcePath: sourcePath, result: result)
case "clipboardHasImage":
result(UIPasteboard.general.hasImages)
case "readClipboardImage":
guard let imageData = Self.clipboardImageData(from: UIPasteboard.general) else {
result(nil)
return
}
result(FlutterStandardTypedData(bytes: imageData))
default:
result(FlutterMethodNotImplemented)
}
}
static func clipboardImageData(from pasteboard: UIPasteboard) -> Data? {
if let pngData = pasteboard.data(forPasteboardType: "public.png") {
return pngData
}
if let jpegData = pasteboard.data(forPasteboardType: "public.jpeg") {
return jpegData
}
for imageType in ["public.heic", "public.heif", "org.webmproject.webp", "com.compuserve.gif"] {
if let imageData = pasteboard.data(forPasteboardType: imageType) {
return imageData
}
}
guard let image = pasteboard.image else {
return nil
}
return image.pngData()
}
private func transcodeVideoToMp4(
sourcePath: String,
result: @escaping FlutterResult
) {
let sourceURL = URL(fileURLWithPath: sourcePath)
let asset = AVURLAsset(url: sourceURL)
// Do not export the source asset directly. An iPhone video can carry GPS,
// spatial-video, and other data tracks even when its user-visible metadata
// is cleared. A fresh composition copies only one video and one audio
// track, so those private channels cannot reach the relay.
let composition = AVMutableComposition()
guard
let sourceVideo = asset.tracks(withMediaType: .video).first,
let destinationVideo = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
)
else {
result(
FlutterError(
code: "transcode_failed",
message: "The selected file does not contain a video track.",
details: nil
)
)
return
}
do {
let sourceAudio = asset.tracks(withMediaType: .audio).first
let insertionTimes = Self.relativeTrackInsertionTimes(
videoStart: sourceVideo.timeRange.start,
audioStart: sourceAudio?.timeRange.start
)
try destinationVideo.insertTimeRange(
sourceVideo.timeRange,
of: sourceVideo,
at: insertionTimes.video
)
destinationVideo.preferredTransform = sourceVideo.preferredTransform
if let sourceAudio,
let destinationAudio = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
)
{
try destinationAudio.insertTimeRange(
sourceAudio.timeRange,
of: sourceAudio,
at: insertionTimes.audio ?? .zero
)
}
} catch {
result(
FlutterError(
code: "transcode_failed",
message: error.localizedDescription,
details: nil
)
)
return
}
guard
let exportSession = AVAssetExportSession(
asset: composition,
// Passthrough preserves the source's HEVC codec and container
// metadata. Buzz accepts only canonical H.264/AAC MP4s with no
// metadata channels, so re-encode instead of copying the movie.
presetName: AVAssetExportPresetMediumQuality
)
else {
result(
FlutterError(
code: "transcode_failed",
message: "Unable to create export session.",
details: nil
)
)
return
}
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
// `forSharing()` intentionally retains playback metadata. The relay
// rejects every descriptive metadata channel to avoid leaking location or
// other private information, so write no source metadata at all.
exportSession.metadata = []
exportSession.metadataItemFilter = nil
exportSession.exportAsynchronously {
switch exportSession.status {
case .completed:
do {
// AVFoundation writes a standard sample-dependency table (`sdtp`).
// Older Buzz relays mistook that playback-only box for metadata. Keep
// its size and payload in a `free` box so chunk offsets stay valid and
// uploads work before those relays receive the validator fix.
try MP4Canonicalizer.neutralizeSampleDependencyBoxes(at: outputURL)
result(outputURL.path)
} catch {
try? FileManager.default.removeItem(at: outputURL)
result(
FlutterError(
code: "transcode_failed",
message: "Unable to canonicalize transcoded video.",
details: error.localizedDescription
)
)
}
default:
let errorMessage =
exportSession.error?.localizedDescription
?? "Video transcoding failed with status \(exportSession.status.rawValue)."
result(
FlutterError(
code: "transcode_failed",
message: errorMessage,
details: nil
)
)
// Clean up partial output on failure.
try? FileManager.default.removeItem(at: outputURL)
}
}
}
static func relativeTrackInsertionTimes(
videoStart: CMTime,
audioStart: CMTime?
) -> (video: CMTime, audio: CMTime?) {
guard let audioStart else {
return (video: .zero, audio: nil)
}
let timelineStart =
CMTimeCompare(audioStart, videoStart) < 0 ? audioStart : videoStart
return (
video: CMTimeSubtract(videoStart, timelineStart),
audio: CMTimeSubtract(audioStart, timelineStart)
)
}
private func generateVideoPoster(
sourcePath: String,
result: @escaping FlutterResult
) {
DispatchQueue.global(qos: .userInitiated).async {
let asset = AVURLAsset(url: URL(fileURLWithPath: sourcePath))
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 720, height: 720)
generator.requestedTimeToleranceBefore = .positiveInfinity
generator.requestedTimeToleranceAfter = .positiveInfinity
do {
let durationSeconds = CMTimeGetSeconds(asset.duration)
let middleTime =
durationSeconds.isFinite && durationSeconds > 0
? min(durationSeconds / 2, 1)
: 0
let candidateTimes = [0, 0.1, middleTime]
var posterImage: CGImage?
var lastError: Error?
for seconds in candidateTimes {
do {
posterImage = try generator.copyCGImage(
at: CMTime(seconds: seconds, preferredTimescale: 600),
actualTime: nil
)
if posterImage != nil { break }
} catch {
lastError = error
}
}
guard let posterImage else {
throw lastError
?? NSError(
domain: "BuzzVideoPoster",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."]
)
}
guard let jpegData = try MediaSanitizer.encodeJpeg(UIImage(cgImage: posterImage)) else {
throw NSError(
domain: "BuzzVideoPoster",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Unable to encode video poster."]
)
}
DispatchQueue.main.async {
result(FlutterStandardTypedData(bytes: jpegData))
}
} catch {
DispatchQueue.main.async {
result(
FlutterError(
code: "poster_failed",
message: "Unable to create a video preview.",
details: error.localizedDescription
)
)
}
}
}
}
}
extension BuzzPushNavigationTarget {
fileprivate var flutterArguments: [String: String] {
[
"eventId": eventID,
"communityId": communityID,
"channelId": channelID,
]
}
}