mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Tapping a push notification for a thread reply currently opens the channel at its parent. Preserve the verified event's direct-parent thread ID through the iOS notification payload and Flutter bridge so the tap opens the containing thread and highlights that exact reply, even when newer replies have arrived. This matches Inbox's destination selection for nested replies; broadcast replies still open in the channel. Closest related work: https://github.com/block/buzz/pull/6159 (original push-tap routing). ### Validation - Follow-up [5b70e1a](5b70e1a5cb) selects the last valid 64-character ASCII-hex reply marker, matching relay ingest. Regression tests failed before the fix; all 80 XCTest and 47 Swift Testing tests passed afterward with Xcode 26.5. [Current-head CI](https://github.com/block/buzz/actions/runs/38607128406) passed, including Flutter tests, Android build, native package tests, complete iOS simulator and unsigned release builds, and native accessibility tests. The [current-head security review](https://github.com/block/buzz/pull/8294#issuecomment-6174205374) reports no findings. Physical evidence below predates this validation-only follow-up. - [Baseline CI onf465f91](https://github.com/block/buzz/actions/runs/38436936153) passed, including Flutter and native package tests, Android build, complete iOS simulator and unsigned release builds, and native accessibility tests. - Full local validation passed on [f465f91](f465f9196f), including 3,215 Flutter tests (four skipped) and three unconfigured-push tests. The 49 focused navigation tests also passed after the baseline merge. - Added coverage for cold/warm bridge delivery, exact reply highlighting with newer arrivals, direct-parent and broadcast destinations, and malformed optional thread IDs. The Codex finding for an empty reply marker is fixed and resolved. - Physical acceptance passed on the managed iPhone 12 mini using signed Buzz Dev 10140, built fromf465f91with SDK 26.5. Real push taps and Inbox opened the same containing thread and highlighted the exact reply. An older alert retained its target after a newer reply arrived; a retained alert also worked after terminating the app. Top-level push navigation reached the channel. - Installed in place with the existing identity, app data and pairing retained. No key reset or rotation was performed. Notifications were restored to off and device control released. Xcode 27 hits an unchanged dependency compiler error; the matching Xcode 26.5 toolchain built and signed this tested candidate successfully. ### Physical acceptance | Warm push: older reply A | Inbox: reply B | Cold push: reply B | | --- | --- | --- | |  |  |  | [Physical test details](https://github.com/block/buzz/pull/8294#issuecomment-6151991840). ### Before and after Synthetic Flutter widget captures with a newer reply already present. | Before: channel at outer parent | After: exact reply in containing thread | | --- | --- | |  |  | After merge, repeat warm/cold push versus Inbox in the integrated mobile candidate, including older alerts, top-level and broadcast notifications. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
1086 lines
35 KiB
Swift
1086 lines
35 KiB
Swift
import AVFoundation
|
|
import BuzzPushKit
|
|
import DeclaredAgeRange
|
|
import Flutter
|
|
import UIKit
|
|
import UserNotifications
|
|
import os.log
|
|
|
|
@main
|
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
|
private var nativeMessagePresentationCoordinator: NativeMessagePresentationCoordinator?
|
|
private var mediaUploadChannel: FlutterMethodChannel?
|
|
private var pushChannel: FlutterMethodChannel?
|
|
private let apnsRegistrationBuffer = APNsRegistrationBuffer()
|
|
private let pushNavigationBuffer = BuzzPushNavigationBuffer()
|
|
private var apnsDeviceToken: Data?
|
|
private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore(
|
|
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
|
)
|
|
private var enrollmentTask: Task<Void, Never>?
|
|
var appGroupIdentifier: String? {
|
|
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
|
|
}
|
|
private var pushKeychainAccessGroup: String? {
|
|
Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
|
}
|
|
private lazy var pushSnapshotBridge = BuzzPushSnapshotBridge(
|
|
appGroupIdentifier: appGroupIdentifier,
|
|
endpointGrantStore: endpointGrantStore,
|
|
keychainAccessGroup: pushKeychainAccessGroup
|
|
)
|
|
private var 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 nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator?
|
|
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
|
|
private var huddleMediaPlugin: HuddleMediaPlugin?
|
|
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
// Age checking and notification restoration run asynchronously from
|
|
// Flutter. No age-related storage or platform request may delay launch.
|
|
UNUserNotificationCenter.current().delegate = self
|
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
}
|
|
|
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
let messenger = engineBridge.applicationRegistrar.messenger()
|
|
huddleMediaPlugin = HuddleMediaPlugin(messenger: messenger)
|
|
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
|
|
)
|
|
|
|
let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativeProfileTextEditor"
|
|
)
|
|
nativeProfileTextEditorCoordinator = NativeProfileTextEditorCoordinator(
|
|
messenger: messenger,
|
|
parentViewController: nativeProfileTextEditorRegistrar?.viewController
|
|
)
|
|
if #available(iOS 16.0, *),
|
|
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
|
|
forPlugin: "BuzzNativeMessageActionSurface"
|
|
)
|
|
{
|
|
nativeMessageActionsRegistrar.register(
|
|
NativeMessageActionSurfaceFactory(messenger: messenger),
|
|
withId: "buzz/native_message_action_surface"
|
|
)
|
|
nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
|
|
name: "buzz/native_message_action_surface",
|
|
binaryMessenger: messenger
|
|
)
|
|
nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
|
|
guard call.method == "isSupported" else {
|
|
result(FlutterMethodNotImplemented)
|
|
return
|
|
}
|
|
result(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleAgeSignalMethodCall(
|
|
_ call: FlutterMethodCall,
|
|
viewController: UIViewController?,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
// iOS can retire the request in process. The generation fence prevents
|
|
// a late result from the cancelled task from completing a fresh request.
|
|
if call.method == "cancelAgeSignalRequest" || call.method == "restartForAgeSignal" {
|
|
cancelAgeSignalRequest()
|
|
result(true)
|
|
return
|
|
}
|
|
guard call.method == "requestAgeSignal" else {
|
|
result(FlutterMethodNotImplemented)
|
|
return
|
|
}
|
|
guard #available(iOS 26.0, *) else {
|
|
result(Self.noAgeSignalResponse)
|
|
return
|
|
}
|
|
guard let viewController else {
|
|
result(
|
|
FlutterError(
|
|
code: "age_signal_unavailable",
|
|
message: "The age signal presenter is unavailable.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
let requestID = UUID()
|
|
ageSignalRequestID = requestID
|
|
ageSignalResult = result
|
|
let request = requestPlatformAgeSignal
|
|
ageSignalTask = Task { @MainActor [weak self] in
|
|
do {
|
|
let payload = try await request(viewController)
|
|
self?.completeAgeSignalRequest(requestID, value: payload)
|
|
} catch {
|
|
self?.completeAgeSignalRequest(
|
|
requestID,
|
|
value:
|
|
FlutterError(
|
|
code: "age_signal_unavailable",
|
|
message: "The age signal request failed.",
|
|
details: String(describing: type(of: error))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private static func platformAgeSignal(_ viewController: UIViewController) async throws -> [String: Any] {
|
|
guard #available(iOS 26.0, *) else { return noAgeSignalResponse }
|
|
let response = try await AgeRangeService.shared.requestAgeRange(ageGates: 18, in: viewController)
|
|
switch response {
|
|
case .declinedSharing:
|
|
return noAgeSignalResponse
|
|
case .sharing(let range):
|
|
return BuzzAgeSignalPayload.sharing(
|
|
exclusiveUpperBound: range.upperBound, lowerBound: range.lowerBound)
|
|
@unknown default:
|
|
throw NSError(domain: "BuzzAgeSignal", code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unsupported age signal response"])
|
|
}
|
|
}
|
|
|
|
private func completeAgeSignalRequest(_ requestID: UUID, value: Any?) {
|
|
guard ageSignalRequestID == requestID, let result = ageSignalResult else { return }
|
|
ageSignalRequestID = nil
|
|
ageSignalResult = nil
|
|
ageSignalTask = nil
|
|
result(value)
|
|
}
|
|
|
|
private func cancelAgeSignalRequest() {
|
|
let result = ageSignalResult
|
|
ageSignalRequestID = nil
|
|
ageSignalResult = nil
|
|
ageSignalTask?.cancel()
|
|
ageSignalTask = nil
|
|
result?(
|
|
FlutterError(
|
|
code: "age_signal_cancelled",
|
|
message: "The age signal request was cancelled.",
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
private static let noAgeSignalResponse: [String: Any] = [
|
|
"status": "noSignal",
|
|
"ageUpper": NSNull(),
|
|
]
|
|
|
|
private static func handleQrScannerMethodCall(
|
|
_ call: FlutterMethodCall,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
switch call.method {
|
|
case "usesDynamicIslandQrScannerPortal":
|
|
result(
|
|
UIDevice.current.userInterfaceIdiom == .phone
|
|
&& usesDynamicIslandQrScannerPortal(
|
|
safeAreaTopInset: activeWindowSafeAreaTopInset()
|
|
)
|
|
)
|
|
case "setDynamicIslandScannerStatusBarHidden":
|
|
guard let hidden = call.arguments as? Bool else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Expected a Bool status-bar visibility value.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
UIApplication.shared.setStatusBarHidden(hidden, with: .fade)
|
|
result(nil)
|
|
case "performDynamicIslandQrScanSuccessHaptic":
|
|
let generator = UINotificationFeedbackGenerator()
|
|
generator.prepare()
|
|
generator.notificationOccurred(.success)
|
|
result(nil)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
static func usesDynamicIslandQrScannerPortal(
|
|
safeAreaTopInset: CGFloat
|
|
) -> Bool {
|
|
safeAreaTopInset > 50
|
|
}
|
|
|
|
private static func activeWindowSafeAreaTopInset() -> CGFloat {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.filter { $0.activationState == .foregroundActive }
|
|
.flatMap(\.windows)
|
|
.first(where: \.isKeyWindow)?
|
|
.safeAreaInsets.top ?? 0
|
|
}
|
|
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
|
) {
|
|
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
|
|
apnsDeviceToken = deviceToken
|
|
apnsRegistrationBuffer.recordToken(deviceToken)
|
|
}
|
|
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didFailToRegisterForRemoteNotificationsWithError error: Error
|
|
) {
|
|
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
|
|
apnsRegistrationBuffer.recordError(error.localizedDescription)
|
|
}
|
|
|
|
override func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse,
|
|
withCompletionHandler completionHandler: @escaping () -> Void
|
|
) {
|
|
BuzzPushNotificationResponseCoordinator.handle(
|
|
actionIdentifier: response.actionIdentifier,
|
|
userInfo: response.notification.request.content.userInfo,
|
|
onTarget: { target in
|
|
pushNavigationBuffer.record(target)
|
|
deliverPushNavigationTarget(target)
|
|
},
|
|
forwardToFlutter: { pluginCompletion in
|
|
self.forwardPushNotificationResponseToFlutter(
|
|
center,
|
|
response: response,
|
|
completion: pluginCompletion
|
|
)
|
|
},
|
|
completion: completionHandler
|
|
)
|
|
}
|
|
|
|
private func forwardPushNotificationResponseToFlutter(
|
|
_ center: UNUserNotificationCenter,
|
|
response: UNNotificationResponse,
|
|
completion: @escaping () -> Void
|
|
) {
|
|
super.userNotificationCenter(
|
|
center,
|
|
didReceive: response,
|
|
withCompletionHandler: completion
|
|
)
|
|
}
|
|
|
|
private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) {
|
|
pushChannel?.invokeMethod(
|
|
"notificationOpened",
|
|
arguments: target.flutterArguments
|
|
) { [weak self] result in
|
|
guard result as? String == "handled" else { return }
|
|
self?.pushNavigationBuffer.remove(ifMatching: target)
|
|
}
|
|
}
|
|
|
|
private func handlePushMethodCall(
|
|
_ call: FlutterMethodCall,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
if pushSnapshotBridge.handle(call, result: result) {
|
|
return
|
|
}
|
|
switch call.method {
|
|
case "startRegistration":
|
|
startPushRegistration(result: result)
|
|
case "takePendingNotificationResponse":
|
|
result(pushNavigationBuffer.take()?.flutterArguments)
|
|
case "notificationAuthorizationStatus":
|
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
DispatchQueue.main.async {
|
|
result(Self.pushAuthorizationStatusName(settings.authorizationStatus))
|
|
}
|
|
}
|
|
case "openNotificationSettings":
|
|
openNotificationSettings(result: result)
|
|
case "endpointGrants":
|
|
do {
|
|
guard let arguments = call.arguments as? [String: Any],
|
|
let gatewayText = arguments["gatewayUrl"] as? String,
|
|
let gatewayURL = URL(string: gatewayText)
|
|
else { throw BuzzDevPushEnrollmentError.invalidGatewayURL }
|
|
let driver = try BuzzDevPushEnrollmentDriver(
|
|
gatewayBaseURL: gatewayURL,
|
|
store: endpointGrantStore,
|
|
appAttestKeychainAccessGroup: pushKeychainAccessGroup
|
|
)
|
|
result(try driver.endpointGrants().map(\.flutterArguments))
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "endpoint_grant_read_failed",
|
|
message: "Unable to read persisted push endpoint grants.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
case "enrollPush":
|
|
handleDevPushEnrollment(call, result: result)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
static func pushAuthorizationStatusName(_ status: UNAuthorizationStatus) -> String {
|
|
switch status {
|
|
case .notDetermined:
|
|
return "notDetermined"
|
|
case .denied:
|
|
return "denied"
|
|
case .authorized:
|
|
return "authorized"
|
|
case .provisional:
|
|
return "provisional"
|
|
case .ephemeral:
|
|
return "ephemeral"
|
|
@unknown default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
private func openNotificationSettings(result: @escaping FlutterResult) {
|
|
let settingsURLString: String
|
|
if #available(iOS 16.0, *) {
|
|
settingsURLString = UIApplication.openNotificationSettingsURLString
|
|
} else {
|
|
settingsURLString = UIApplication.openSettingsURLString
|
|
}
|
|
guard let url = URL(string: settingsURLString) else {
|
|
result(false)
|
|
return
|
|
}
|
|
UIApplication.shared.open(url, options: [:]) { opened in
|
|
DispatchQueue.main.async {
|
|
result(opened)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startPushRegistration(result: @escaping FlutterResult) {
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) {
|
|
_, error in
|
|
if let error {
|
|
os_log(
|
|
"Buzz notification authorization request failed: %{public}@",
|
|
type: .error,
|
|
error.localizedDescription
|
|
)
|
|
}
|
|
}
|
|
// APNs token registration is independent from display authorization. A
|
|
// denied or failed prompt must not prevent gateway enrollment and leases.
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
result(nil)
|
|
}
|
|
|
|
private func handleDevPushEnrollment(
|
|
_ call: FlutterMethodCall,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
guard enrollmentTask == nil else {
|
|
result(
|
|
FlutterError(
|
|
code: "enrollment_in_progress",
|
|
message: "Development push enrollment is already running.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
guard let deviceToken = apnsDeviceToken else {
|
|
result(
|
|
FlutterError(
|
|
code: "missing_apns_token",
|
|
message: "APNs has not supplied a device token.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
guard !deviceToken.isEmpty else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_apns_token",
|
|
message: "APNs supplied an empty device token.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
guard let arguments = call.arguments as? [String: Any],
|
|
let relayText = arguments["relayUrl"] as? String,
|
|
let relayURL = URL(string: relayText),
|
|
let gatewayText = arguments["gatewayUrl"] as? String,
|
|
let gatewayURL = URL(string: gatewayText)
|
|
else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Development push enrollment requires relayUrl and gatewayUrl.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
do {
|
|
let driver = try BuzzDevPushEnrollmentDriver(
|
|
gatewayBaseURL: gatewayURL,
|
|
store: endpointGrantStore,
|
|
appAttestKeychainAccessGroup: Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
|
|
) as? String
|
|
)
|
|
enrollmentTask = Task { [weak self] in
|
|
defer { self?.enrollmentTask = nil }
|
|
do {
|
|
let record = try await driver.enroll(
|
|
deviceToken: deviceToken,
|
|
relayURL: relayURL
|
|
)
|
|
await MainActor.run { result(record.flutterArguments) }
|
|
} catch {
|
|
await MainActor.run {
|
|
result(
|
|
FlutterError(
|
|
code: "dev_enrollment_failed",
|
|
message: "Development push enrollment failed.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "dev_enrollment_configuration_failed",
|
|
message: "Development push enrollment is not configured.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleMediaUploadMethodCall(
|
|
_ call: FlutterMethodCall,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
switch call.method {
|
|
case "sanitizeImageForUpload":
|
|
guard
|
|
let arguments = call.arguments as? [String: Any],
|
|
let typedData = arguments["bytes"] as? FlutterStandardTypedData,
|
|
let mimeType = arguments["mimeType"] as? String
|
|
else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Expected image bytes and mime type.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
guard let image = UIImage(data: typedData.data) else {
|
|
result(
|
|
FlutterError(
|
|
code: "sanitize_failed",
|
|
message: "Unable to decode picked image.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
do {
|
|
guard let sanitizedData = try MediaSanitizer.sanitizeImage(image, mimeType: mimeType) else {
|
|
result(
|
|
FlutterError(
|
|
code: "sanitize_failed",
|
|
message: "Unable to sanitize picked image.",
|
|
details: mimeType
|
|
)
|
|
)
|
|
return
|
|
}
|
|
result(FlutterStandardTypedData(bytes: sanitizedData))
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "sanitize_failed",
|
|
message: "Unable to sanitize picked image.",
|
|
details: mimeType
|
|
)
|
|
)
|
|
}
|
|
case "transcodeImageToJpeg":
|
|
guard let typedData = call.arguments as? FlutterStandardTypedData else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Expected raw image bytes.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
guard let image = UIImage(data: typedData.data) else {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to convert picked image to JPEG.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
do {
|
|
guard let jpegData = try MediaSanitizer.encodeJpeg(image) else {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to convert picked image to JPEG.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
result(FlutterStandardTypedData(bytes: jpegData))
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to convert picked image to JPEG.",
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
case "transcodeVideoToMp4":
|
|
guard let sourcePath = call.arguments as? String else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Expected source file path as String.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
transcodeVideoToMp4(sourcePath: sourcePath, result: result)
|
|
case "packageVoiceNoteForUpload":
|
|
guard let sourcePath = call.arguments as? String else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Expected source file path as String.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
VoiceNotePackager.package(sourcePath: sourcePath, result: result)
|
|
case "generateVideoPoster":
|
|
guard let sourcePath = call.arguments as? String else {
|
|
result(
|
|
FlutterError(
|
|
code: "invalid_arguments",
|
|
message: "Expected source file path as String.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
generateVideoPoster(sourcePath: sourcePath, result: result)
|
|
case "clipboardHasImage":
|
|
result(UIPasteboard.general.hasImages)
|
|
case "readClipboardImage":
|
|
guard let imageData = Self.clipboardImageData(from: UIPasteboard.general) else {
|
|
result(nil)
|
|
return
|
|
}
|
|
result(FlutterStandardTypedData(bytes: imageData))
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
static func clipboardImageData(from pasteboard: UIPasteboard) -> Data? {
|
|
if let pngData = pasteboard.data(forPasteboardType: "public.png") {
|
|
return pngData
|
|
}
|
|
if let jpegData = pasteboard.data(forPasteboardType: "public.jpeg") {
|
|
return jpegData
|
|
}
|
|
for imageType in ["public.heic", "public.heif", "org.webmproject.webp", "com.compuserve.gif"] {
|
|
if let imageData = pasteboard.data(forPasteboardType: imageType) {
|
|
return imageData
|
|
}
|
|
}
|
|
guard let image = pasteboard.image else {
|
|
return nil
|
|
}
|
|
return image.pngData()
|
|
}
|
|
|
|
private func transcodeVideoToMp4(
|
|
sourcePath: String,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
let sourceURL = URL(fileURLWithPath: sourcePath)
|
|
let asset = AVURLAsset(url: sourceURL)
|
|
|
|
// Do not export the source asset directly. An iPhone video can carry GPS,
|
|
// spatial-video, and other data tracks even when its user-visible metadata
|
|
// is cleared. A fresh composition copies only one video and one audio
|
|
// track, so those private channels cannot reach the relay.
|
|
let composition = AVMutableComposition()
|
|
guard
|
|
let sourceVideo = asset.tracks(withMediaType: .video).first,
|
|
let destinationVideo = composition.addMutableTrack(
|
|
withMediaType: .video,
|
|
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
)
|
|
else {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "The selected file does not contain a video track.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
do {
|
|
let sourceAudio = asset.tracks(withMediaType: .audio).first
|
|
let insertionTimes = Self.relativeTrackInsertionTimes(
|
|
videoStart: sourceVideo.timeRange.start,
|
|
audioStart: sourceAudio?.timeRange.start
|
|
)
|
|
try destinationVideo.insertTimeRange(
|
|
sourceVideo.timeRange,
|
|
of: sourceVideo,
|
|
at: insertionTimes.video
|
|
)
|
|
destinationVideo.preferredTransform = sourceVideo.preferredTransform
|
|
|
|
if let sourceAudio,
|
|
let destinationAudio = composition.addMutableTrack(
|
|
withMediaType: .audio,
|
|
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
)
|
|
{
|
|
try destinationAudio.insertTimeRange(
|
|
sourceAudio.timeRange,
|
|
of: sourceAudio,
|
|
at: insertionTimes.audio ?? .zero
|
|
)
|
|
}
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: error.localizedDescription,
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
guard
|
|
let exportSession = AVAssetExportSession(
|
|
asset: composition,
|
|
// Passthrough preserves the source's HEVC codec and container
|
|
// metadata. Buzz accepts only canonical H.264/AAC MP4s with no
|
|
// metadata channels, so re-encode instead of copying the movie.
|
|
presetName: AVAssetExportPresetMediumQuality
|
|
)
|
|
else {
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to create export session.",
|
|
details: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
let outputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
.appendingPathExtension("mp4")
|
|
|
|
exportSession.outputURL = outputURL
|
|
exportSession.outputFileType = .mp4
|
|
exportSession.shouldOptimizeForNetworkUse = true
|
|
// `forSharing()` intentionally retains playback metadata. The relay
|
|
// rejects every descriptive metadata channel to avoid leaking location or
|
|
// other private information, so write no source metadata at all.
|
|
exportSession.metadata = []
|
|
exportSession.metadataItemFilter = nil
|
|
|
|
exportSession.exportAsynchronously {
|
|
switch exportSession.status {
|
|
case .completed:
|
|
do {
|
|
// AVFoundation writes a standard sample-dependency table (`sdtp`).
|
|
// Older Buzz relays mistook that playback-only box for metadata. Keep
|
|
// its size and payload in a `free` box so chunk offsets stay valid and
|
|
// uploads work before those relays receive the validator fix.
|
|
try MP4Canonicalizer.neutralizeSampleDependencyBoxes(at: outputURL)
|
|
result(outputURL.path)
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: "Unable to canonicalize transcoded video.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
default:
|
|
let errorMessage =
|
|
exportSession.error?.localizedDescription
|
|
?? "Video transcoding failed with status \(exportSession.status.rawValue)."
|
|
result(
|
|
FlutterError(
|
|
code: "transcode_failed",
|
|
message: errorMessage,
|
|
details: nil
|
|
)
|
|
)
|
|
// Clean up partial output on failure.
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func relativeTrackInsertionTimes(
|
|
videoStart: CMTime,
|
|
audioStart: CMTime?
|
|
) -> (video: CMTime, audio: CMTime?) {
|
|
guard let audioStart else {
|
|
return (video: .zero, audio: nil)
|
|
}
|
|
|
|
let timelineStart =
|
|
CMTimeCompare(audioStart, videoStart) < 0 ? audioStart : videoStart
|
|
return (
|
|
video: CMTimeSubtract(videoStart, timelineStart),
|
|
audio: CMTimeSubtract(audioStart, timelineStart)
|
|
)
|
|
}
|
|
|
|
private func generateVideoPoster(
|
|
sourcePath: String,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let asset = AVURLAsset(url: URL(fileURLWithPath: sourcePath))
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.appliesPreferredTrackTransform = true
|
|
generator.maximumSize = CGSize(width: 720, height: 720)
|
|
generator.requestedTimeToleranceBefore = .positiveInfinity
|
|
generator.requestedTimeToleranceAfter = .positiveInfinity
|
|
|
|
do {
|
|
let durationSeconds = CMTimeGetSeconds(asset.duration)
|
|
let middleTime =
|
|
durationSeconds.isFinite && durationSeconds > 0
|
|
? min(durationSeconds / 2, 1)
|
|
: 0
|
|
let candidateTimes = [0, 0.1, middleTime]
|
|
var posterImage: CGImage?
|
|
var lastError: Error?
|
|
|
|
for seconds in candidateTimes {
|
|
do {
|
|
posterImage = try generator.copyCGImage(
|
|
at: CMTime(seconds: seconds, preferredTimescale: 600),
|
|
actualTime: nil
|
|
)
|
|
if posterImage != nil { break }
|
|
} catch {
|
|
lastError = error
|
|
}
|
|
}
|
|
|
|
guard let posterImage else {
|
|
throw lastError
|
|
?? NSError(
|
|
domain: "BuzzVideoPoster",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."]
|
|
)
|
|
}
|
|
guard let jpegData = try MediaSanitizer.encodeJpeg(UIImage(cgImage: posterImage)) else {
|
|
throw NSError(
|
|
domain: "BuzzVideoPoster",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to encode video poster."]
|
|
)
|
|
}
|
|
DispatchQueue.main.async {
|
|
result(FlutterStandardTypedData(bytes: jpegData))
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
result(
|
|
FlutterError(
|
|
code: "poster_failed",
|
|
message: "Unable to create a video preview.",
|
|
details: error.localizedDescription
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension BuzzPushNavigationTarget {
|
|
fileprivate var flutterArguments: [String: String] {
|
|
var arguments = [
|
|
"eventId": eventID,
|
|
"communityId": communityID,
|
|
"channelId": channelID,
|
|
]
|
|
arguments["threadRootId"] = threadRootID
|
|
return arguments
|
|
}
|
|
}
|