mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
Lets people share screenshots, screen recordings, photos, URLs and text from any iOS app into Buzz. Today Buzz doesn't appear in the share sheet at all. - **Plain Buzz icon:** opens a new Solo chat with the items in its composer. - **Suggested conversation:** iOS offers channels and DMs the person recently sent top-level messages in. Choosing one opens that conversation with the items in its composer. When the conversation is already open, the share goes to that page's composer instead of opening a second one; if another page covers it, Buzz stays where it is and shows "Go back to that conversation to finish sharing", and the items join the draft when the person goes back. - The share extension has no UI and sends nothing. It copies the items into the app group and opens Buzz, which prefills the normal composer, so drafts, attachments, mentions and failed-send handling all stay the app's (see [the ADR](https://github.com/block/buzz/blob/mobile-share-extension-design/docs/adr/0003-share-hands-off-to-the-conversation.md)). - If the conversation already has a draft, Buzz asks whether to add the shared items to it or replace it. - A share to a conversation that's no longer available (a removed community or channel, or an unreadable share) shows "That conversation isn't available". A share while signed out shows "Sign in to share". Suggestions come from an outgoing `INSendMessageIntent` the app donates after a top-level send or a new Solo chat. The notification extension no longer donates incoming messages, so receiving a message never makes a conversation a suggestion; it still turns the push into a communication notification with the sender's avatar. Both use the same conversation identifier, now the plain community and channel IDs instead of a hash, because the extension needs to hand the identifier back to the app. | Before | After | | --- | --- | | <img alt="Share sheet without Buzz" src="https://github.com/user-attachments/assets/452fac1b-7e87-44c3-a442-099f78d0e07b" width="300"> | <img alt="Share sheet with Buzz" src="https://github.com/user-attachments/assets/54b9775d-29b4-49a5-8ab9-5fda8718a02f" width="300"> | | Buzz icon: new Solo chat | Suggested DM with a draft | After Add | | --- | --- | --- | | <img alt="New Solo chat with the shared photo" src="https://github.com/user-attachments/assets/c5da4d8b-1dd8-4e30-9e92-9dba515b3623" width="250"> | <img alt="Add to your draft prompt" src="https://github.com/user-attachments/assets/2f879f8a-3b9f-48e0-b963-2828e90dc9fe" width="250"> | <img alt="Draft with the shared link and photo added" src="https://github.com/user-attachments/assets/12832006-85c5-4447-98b5-62932552c931" width="250"> | https://github.com/user-attachments/assets/b9d380fa-e802-4ed7-89a8-592a03784c8f ### Regressions - Nothing changes for people who never use the share sheet, except that sending a top-level message or starting a Solo chat now makes one local `INInteraction.donate` call (skipped while the age restriction is on, which still purges every interaction). - Communication notifications look the same. Dropping the incoming donation removes work (and a deletion fence) from the notification extension's time budget. - The share hand-off reuses the existing deep link dispatcher. A share link is a new link type; notification and message links take the same paths as before. ### Validation - Added tests: - Dispatcher hand-off: a share to a suggested channel opens it with the shared text and files; shares to a removed community, an unlisted channel, an unknown manifest version or a missing manifest show the error and delete the pending share; share links accept only the extension's UUID ids. - Composer delivery: with an existing draft, Add appends the shared text on a new line and attaches the file, Replace clears the draft first, and the app group copy is deleted either way. - Notification presenter: specializes the notification from the intent, or falls back to the ordinary content when that throws (replaces the donation and deletion-fence tests). - Full mobile Flutter suite and BuzzPushKit Swift tests pass. - Automated app run on an iPhone 17 Pro simulator (iOS 27, dev relay test community), recorded above: sent a DM, shared a photo from Photos with the Buzz icon from a cold start (new Solo chat with the photo attached), then handed a link and photo to a DM that had a draft and chose Add. Also checked that a share's pending files are deleted after delivery. - Limitation: the iOS simulator never shows share-sheet conversation suggestions (it has no `coreduetd`), so the suggested-DM step in the video is staged by writing the same pending share and link the extension writes. Choosing a real suggestion still needs a check on a device. - Known gaps, all confined to sharing: suggestions have no avatar image yet; a suggestion for a conversation left after signing out shows "isn't available"; choosing Later in the unfinished-chat dialog keeps the share until the next new chat; and a pending share the app never receives (for example when Buzz doesn't open) stays in the app group. ### Before merge - [x] On a real iPhone, choose a suggested conversation in the share sheet and confirm the shared items open in that conversation's composer (the simulator can't show suggestions). Waived before merge: the managed test iPhone shows no share-sheet suggestions for any app, so this will be checked on a personal phone after landing. --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com>
1197 lines
41 KiB
Swift
1197 lines
41 KiB
Swift
import AuthenticationServices
|
|
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 communityKeyboardChannel: FlutterMethodChannel?
|
|
private var communityKeyboardObserver: NSObjectProtocol?
|
|
private var hapticsChannel: FlutterMethodChannel?
|
|
private var appGroupChannel: FlutterMethodChannel?
|
|
private var shareSuggestions: ShareSuggestions?
|
|
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 nativePhotoZoomCoordinator: NativePhotoZoomCoordinator?
|
|
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
|
|
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
|
|
private var nativeCreateChannelSheetCoordinator: NativeCreateChannelSheetCoordinator?
|
|
private var nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator?
|
|
private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator?
|
|
private var nativeProfileSetupCoordinator: NativeProfileSetupCoordinator?
|
|
private var nativeCommunityEntryCoordinator: NativeCommunityEntryCoordinator?
|
|
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
|
|
private var identityFilePlugin: IdentityFilePlugin?
|
|
private var hostedHpkeChannel: FlutterMethodChannel?
|
|
private var browserAuthBridge: BuzzBrowserAuthBridge?
|
|
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)
|
|
engineBridge.pluginRegistry.registrar(forPlugin: "BuzzToastLayer")?.register(
|
|
ToastLayerFactory(), withId: "buzz/toast_layer")
|
|
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosMediaCarousel") {
|
|
registrar.register(IosMediaCarouselFactory(messenger: registrar.messenger()), withId: "buzz/ios_media_carousel")
|
|
}
|
|
|
|
let messenger = engineBridge.applicationRegistrar.messenger()
|
|
let keyboardChannel = FlutterMethodChannel(
|
|
name: "buzz/community_keyboard", binaryMessenger: messenger)
|
|
communityKeyboardChannel = keyboardChannel
|
|
if let observer = communityKeyboardObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
communityKeyboardObserver = NotificationCenter.default.addObserver(
|
|
forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main
|
|
) { _ in
|
|
// Deliver after UIKit finishes preparing the keyboard in this run-loop turn.
|
|
DispatchQueue.main.async {
|
|
keyboardChannel.invokeMethod("willShow", arguments: nil)
|
|
}
|
|
}
|
|
identityFilePlugin = IdentityFilePlugin(messenger: messenger)
|
|
HomeScreenShortcuts.shared.attach(messenger: messenger)
|
|
shareSuggestions = ShareSuggestions { [weak self] in
|
|
self?.appGroupIdentifier.flatMap {
|
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0)
|
|
}
|
|
}
|
|
shareSuggestions?.attach(messenger: messenger)
|
|
browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger)
|
|
hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger)
|
|
hostedHpkeChannel?.setMethodCallHandler { call, result in
|
|
if call.method == "isSupported" {
|
|
if #available(iOS 17.0, *) { result(true) } else { result(false) }
|
|
return
|
|
}
|
|
guard call.method == "seal" else { result(FlutterMethodNotImplemented); return }
|
|
guard #available(iOS 17.0, *) else {
|
|
result(FlutterError(code: "unsupported", message: "Backup requires iOS 17 or later", details: nil)); return
|
|
}
|
|
guard let args = call.arguments as? [String: Any],
|
|
let recipient = args["recipient"] as? FlutterStandardTypedData,
|
|
let plaintext = args["plaintext"] as? FlutterStandardTypedData,
|
|
let aad = args["aad"] as? FlutterStandardTypedData else {
|
|
result(FlutterError(code: "invalid_input", message: nil, details: nil)); return
|
|
}
|
|
do {
|
|
let sealed = try HostedHpkeBackup.seal(recipient: recipient.data, plaintext: plaintext.data, aad: aad.data)
|
|
result(["enc": FlutterStandardTypedData(bytes: sealed.enc), "ciphertext": FlutterStandardTypedData(bytes: sealed.ciphertext)])
|
|
} catch {
|
|
result(FlutterError(code: "backup_unavailable", message: nil, details: nil))
|
|
}
|
|
}
|
|
huddleMediaPlugin = HuddleMediaPlugin(messenger: messenger)
|
|
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BuzzIosNavigationBar") {
|
|
registrar.register(IosNavigationBarFactory(messenger: messenger, parent: registrar.viewController), withId: "buzz/ios_navigation_bar")
|
|
}
|
|
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)
|
|
}
|
|
appGroupChannel = FlutterMethodChannel(
|
|
name: "buzz/app_group",
|
|
binaryMessenger: messenger
|
|
)
|
|
appGroupChannel?.setMethodCallHandler { [weak self] call, result in
|
|
let container = self?.appGroupIdentifier.flatMap {
|
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0)
|
|
}
|
|
switch call.method {
|
|
case "cachesDirectory":
|
|
// Caches the app's extensions can read too. The OS may purge it.
|
|
result(container?.appendingPathComponent("Library/Caches", isDirectory: true).path)
|
|
case "pendingSharesDirectory":
|
|
// Where the share extension leaves items for the app to pick up.
|
|
result(container?.appendingPathComponent("PendingShares", isDirectory: true).path)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
hapticsChannel = FlutterMethodChannel(
|
|
name: "buzz/haptics",
|
|
binaryMessenger: messenger
|
|
)
|
|
hapticsChannel?.setMethodCallHandler { call, result in
|
|
guard call.method == "success" || call.method == "error" else {
|
|
result(FlutterMethodNotImplemented)
|
|
return
|
|
}
|
|
let generator = UINotificationFeedbackGenerator()
|
|
generator.prepare()
|
|
generator.notificationOccurred(call.method == "error" ? .error : .success)
|
|
result(nil)
|
|
}
|
|
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
|
|
)
|
|
|
|
nativePhotoZoomCoordinator = NativePhotoZoomCoordinator(
|
|
messenger: messenger,
|
|
parentViewController: engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativePhotoZoom"
|
|
)?.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
|
|
)
|
|
|
|
engineBridge.pluginRegistry.registrar(forPlugin: "BuzzNativePresenceMenu")?.register(
|
|
NativePresenceMenuFactory(messenger: messenger), withId: "buzz/presence_menu"
|
|
)
|
|
|
|
nativeCreateChannelSheetCoordinator = NativeCreateChannelSheetCoordinator(
|
|
messenger: messenger,
|
|
parentViewController: engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativeCreateChannelSheet"
|
|
)?.viewController
|
|
)
|
|
|
|
nativeAddMembersSheetCoordinator = NativeAddMembersSheetCoordinator(
|
|
messenger: messenger,
|
|
parentViewController: engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativeAddMembersSheet"
|
|
)?.viewController
|
|
)
|
|
|
|
nativeConfirmationDialogCoordinator = NativeConfirmationDialogCoordinator(
|
|
messenger: messenger,
|
|
parentViewController: engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativeConfirmationDialog"
|
|
)?.viewController
|
|
)
|
|
|
|
nativeProfileSetupCoordinator = NativeProfileSetupCoordinator(messenger: messenger) { [weak self] engine in
|
|
let channel = FlutterMethodChannel(name: "buzz/media_upload", binaryMessenger: engine.binaryMessenger)
|
|
channel.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) }
|
|
}
|
|
nativeCommunityEntryCoordinator = NativeCommunityEntryCoordinator(messenger: messenger)
|
|
|
|
let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativeProfileTextEditor"
|
|
)
|
|
nativeProfileTextEditorCoordinator = NativeProfileTextEditorCoordinator(
|
|
messenger: messenger,
|
|
parentViewController: nativeProfileTextEditorRegistrar?.viewController
|
|
)
|
|
}
|
|
|
|
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 "cameraCutoutFrame":
|
|
#if canImport(UIKit, _version: 9127.0.85)
|
|
if #available(iOS 27.1, *),
|
|
let scene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive }),
|
|
let window = scene.windows.first(where: { $0.isKeyWindow }) {
|
|
if window.reservedRegions(kind: .division).contains(where: { $0.isActive }) {
|
|
result(nil)
|
|
return
|
|
}
|
|
let bounds = window.bounds
|
|
// Closed Duo displays may omit Flutter display features. UIKit still
|
|
// owns the physical occlusion; remove its interactive clearance.
|
|
for region in window.reservedRegions(kind: .occlusion) where region.isActive {
|
|
let rect = region.frame.inset(by: region.margins)
|
|
let isCamera = abs(rect.width - rect.height) < 1
|
|
&& rect.minX > 0 && rect.minY > 0
|
|
&& rect.maxX < bounds.width && rect.maxY < bounds.height
|
|
&& rect.width < bounds.width / 4
|
|
guard rect.width > 0, rect.height > 0, isCamera else { continue }
|
|
let values: [Double] = [
|
|
Double(rect.minX), Double(rect.minY), Double(rect.width),
|
|
Double(rect.height), Double(bounds.width), Double(bounds.height)
|
|
]
|
|
result(values)
|
|
return
|
|
}
|
|
}
|
|
#endif
|
|
result(nil)
|
|
case "previewRotationQuarterTurns":
|
|
// The plugin reads connectedScenes.first, which can be the inactive
|
|
// cover scene on Duo. Resolve the angle against the visible preview host.
|
|
if #available(iOS 17.0, *),
|
|
let scene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive }),
|
|
let window = scene.windows.first(where: { $0.isKeyWindow }),
|
|
let camera = AVCaptureDevice.DiscoverySession(
|
|
deviceTypes: [.builtInTripleCamera, .builtInDualCamera, .builtInWideAngleCamera],
|
|
mediaType: .video, position: .back
|
|
).devices.first {
|
|
let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: window.layer)
|
|
let angle = coordinator.videoRotationAngleForHorizonLevelPreview
|
|
let orientation = call.arguments as? String
|
|
let legacyAngle: CGFloat
|
|
switch orientation {
|
|
case "landscapeLeft": legacyAngle = 180
|
|
case "landscapeRight": legacyAngle = 0
|
|
case "portraitDown": legacyAngle = 270
|
|
default: legacyAngle = 90
|
|
}
|
|
result(Int(((angle - legacyAngle) / 90).rounded()))
|
|
return
|
|
}
|
|
result(0)
|
|
case "usesDynamicIslandQrScannerPortal":
|
|
#if canImport(UIKit, _version: 9127.0.85)
|
|
if #available(iOS 27.1, *),
|
|
let scene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive }),
|
|
let window = scene.windows.first(where: { $0.isKeyWindow }),
|
|
window.reservedRegions(kind: .division).contains(where: { $0.isActive }) {
|
|
result(false)
|
|
return
|
|
}
|
|
#endif
|
|
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
|
|
}
|
|
let controller = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.flatMap(\.windows)
|
|
.compactMap { $0.rootViewController as? BuzzFlutterViewController }
|
|
.first
|
|
controller?.setScannerStatusBarHidden(hidden)
|
|
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 "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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
var flutterArguments: [String: String] {
|
|
var arguments = [
|
|
"eventId": eventID,
|
|
"communityId": communityID,
|
|
"channelId": channelID,
|
|
]
|
|
arguments["threadRootId"] = threadRootID
|
|
return arguments
|
|
}
|
|
}
|