Files
buzz/mobile/ios/Runner/AppDelegate.swift
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

587 lines
18 KiB
Swift

import AVFoundation
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var mediaUploadChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let messenger = engineBridge.applicationRegistrar.messenger()
mediaUploadChannel = FlutterMethodChannel(
name: "buzz/media_upload",
binaryMessenger: messenger
)
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleMediaUploadMethodCall(call, result: result)
}
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)
}
}
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(),
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)
}
}
}
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
nativeAttachmentPopoverCoordinator = NativeAttachmentPopoverCoordinator(
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
}
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
}
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 "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 Self.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
)
)
}
}
}
}
private static func neutralizeSampleDependencyBoxes(at url: URL) throws {
var data = try Data(contentsOf: url)
try neutralizeSampleDependencyBoxes(in: &data, start: 0, end: data.count)
try data.write(to: url, options: .atomic)
}
private static func neutralizeSampleDependencyBoxes(
in data: inout Data,
start: Int,
end: Int
) throws {
let containers: Set<[UInt8]> = [
Array("moov".utf8), Array("trak".utf8), Array("mdia".utf8),
Array("minf".utf8), Array("stbl".utf8), Array("edts".utf8),
Array("dinf".utf8), Array("sinf".utf8), Array("schi".utf8),
]
let sampleDependencyType = Array("sdtp".utf8)
let freeType = Array("free".utf8)
var offset = start
while offset < end {
guard end - offset >= 8 else { throw invalidMp4BoxError() }
let compactSize = Int(readBigEndianUInt32(data, at: offset))
var headerSize = 8
let boxSize: Int
if compactSize == 1 {
guard end - offset >= 16 else { throw invalidMp4BoxError() }
let extendedSize = readBigEndianUInt64(data, at: offset + 8)
guard extendedSize <= UInt64(Int.max) else { throw invalidMp4BoxError() }
boxSize = Int(extendedSize)
headerSize = 16
} else if compactSize == 0 {
boxSize = end - offset
} else {
boxSize = compactSize
}
guard boxSize >= headerSize, offset + boxSize <= end else {
throw invalidMp4BoxError()
}
let type = Array(data[(offset + 4)..<(offset + 8)])
if type == sampleDependencyType {
data.replaceSubrange((offset + 4)..<(offset + 8), with: freeType)
} else if containers.contains(type) {
try neutralizeSampleDependencyBoxes(
in: &data,
start: offset + headerSize,
end: offset + boxSize
)
}
offset += boxSize
}
}
private static func readBigEndianUInt32(_ data: Data, at offset: Int) -> UInt32 {
data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | UInt32($1) }
}
private static func readBigEndianUInt64(_ data: Data, at offset: Int) -> UInt64 {
data[offset..<(offset + 8)].reduce(0) { ($0 << 8) | UInt64($1) }
}
private static func invalidMp4BoxError() -> NSError {
NSError(
domain: "BuzzVideoTranscode",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid MP4 box structure."]
)
}
}