Files
buzz/mobile/ios/Runner/AppDelegate.swift
klopez4212 ff9eb8f4dc Make mobile onboarding QR-first and synchronize scanner motion (#8599)
Make QR scanning the primary onboarding action, with full-width
contrasting buttons inside the existing gutters. The landing offers
**Scan a QR code** and **or sign in with email**. A two-finger double
tap on the open welcome area reveals **Enter a community link** and
**Use a pairing code**; a screen-reader action provides the same access.
Action buttons remain outside double-tap detection so scanner taps
respond immediately. Pending sign-in keeps its cancellation action.

The Dynamic Island scanner uses 230 ms opening and closing springs.
Welcome content fades and scales with the same progress, camera startup
begins on the first frame, the scanner prompt fades with the camera
reveal, the live preview adds a 230 ms fade when camera startup
completes, and opening gives selection haptics. Native iOS status
indicators request a matching 230 ms fade, returning when dismissal
begins. Reduced motion is respected.

Validation: full mobile suites and analysis run through local checks and
pre-push hooks. The 230 ms iOS Release build was signed, installed, and
launched in the separate Onboarding app on iPhone Air. The owner
reviewed the simplified landing and immediate tap response; the latest
230 ms timing is installed for comparison. The latest 230 ms build,
including the prompt fade, is installed and launched for comparison.
Native status-bar timing has not been independently measured on-device.


Product intent confirmed by Kenny: the supported visible onboarding
choices are email sign-in or QR scanning. Camera permission/startup
failure intentionally does not expose manual pairing or community-link
recovery. Those remain hidden developer options, with no keyboard path
by design.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-27 16:32:04 +00:00

1207 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
}
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 "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()
}
}