Files
buzz/mobile/ios/Runner/AppDelegate.swift
klopez4212 f7708d0694 Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00

1202 lines
40 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 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 nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator?
private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator?
private var nativeProfileSetupCoordinator: NativeProfileSetupCoordinator?
private var nativeCommunityEntryCoordinator: NativeCommunityEntryCoordinator?
private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
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)
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)
browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger)
hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger)
hostedHpkeChannel?.setMethodCallHandler { call, result in
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)
}
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
)
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"
)
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
)
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 {
var flutterArguments: [String: String] {
var arguments = [
"eventId": eventID,
"communityId": communityID,
"channelId": channelID,
]
arguments["threadRootId"] = threadRootID
return arguments
}
}
/// Keeps the authentication callback scoped to the system browser session.
private final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationContextProviding {
private let channel: FlutterMethodChannel
private var session: ASWebAuthenticationSession?
private var pending: FlutterResult?
private var attemptID: UUID?
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "buzz/browser_auth", binaryMessenger: messenger)
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { return }
if call.method == "cancel" {
self.session?.cancel()
self.finish(nil, code: "cancelled")
result(nil)
return
}
guard call.method == "start" else { result(FlutterMethodNotImplemented); return }
guard self.session == nil,
let args = call.arguments as? [String: String],
let raw = args["url"], let url = URL(string: raw),
url.scheme == "https",
["app.builderlab.xyz", "app.builderlabstaging.xyz"].contains(url.host ?? ""),
url.path == "/api/goose/v1/auth/login", url.user == nil, url.password == nil,
url.port == nil, url.fragment == nil
else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
self.pending = result
let attemptID = UUID()
self.attemptID = attemptID
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "xyz.block.buzz.mobile") { [weak self] callback, error in
DispatchQueue.main.async {
guard self?.attemptID == attemptID else { return }
let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
self?.finish(callback?.absoluteString, code: cancelled ? "cancelled" : "unavailable")
}
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false
#if DEBUG
session.prefersEphemeralWebBrowserSession = args["ephemeral"] == "true"
#endif
self.session = session
if !session.start() { self.finish(nil, code: "unavailable") }
}
}
private func finish(_ callback: String?, code: String) {
let result = pending
attemptID = nil
pending = nil
session = nil
if let callback { result?(callback) }
else { result?(FlutterError(code: code, message: nil, details: nil)) }
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
}
}