feat: import Chinese-localized Buzz source snapshot
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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+586
View File
@@ -0,0 +1,586 @@
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."]
)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="168"/>
</resources>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
@@ -0,0 +1,54 @@
import Flutter
import UIKit
final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory {
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
ConcentricSheetSurfacePlatformView(frame: frame, arguments: args)
}
}
final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
private let surfaceView: UIView
init(frame: CGRect, arguments args: Any?) {
let arguments = args as? [String: Any]
let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFFFFFF
let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24
surfaceView = UIView(frame: frame)
surfaceView.isOpaque = true
surfaceView.backgroundColor = Self.color(from: colorValue)
surfaceView.clipsToBounds = true
surfaceView.layer.cornerCurve = .continuous
if #available(iOS 26.0, *) {
surfaceView.cornerConfiguration = .uniformCorners(
radius: .containerConcentric(minimum: minimumRadius)
)
} else {
surfaceView.layer.cornerRadius = minimumRadius
}
super.init()
}
func view() -> UIView {
surfaceView
}
private static func color(from value: UInt32) -> UIColor {
let alpha = CGFloat((value >> 24) & 0xFF) / 255
let red = CGFloat((value >> 16) & 0xFF) / 255
let green = CGFloat((value >> 8) & 0xFF) / 255
let blue = CGFloat(value & 0xFF) / 255
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Buzz</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.buzz.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>buzz</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Buzz needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Buzz needs permission to save images to your photo library.</string>
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+237
View File
@@ -0,0 +1,237 @@
import Flutter
import PhotosUI
import UIKit
import UniformTypeIdentifiers
final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
private weak var parentViewController: UIViewController?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
self.messenger = messenger
self.parentViewController = parentViewController
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
InlinePhotoPickerPlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
parentViewController: parentViewController
)
}
}
final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
private let containerView: UIView
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private var pickerViewController: PHPickerViewController?
private var selectionGeneration = 0
private var selectionTask: Task<Void, Never>?
private var selectedTemporaryPaths: [String] = []
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
containerView = UIView(frame: frame)
channel = FlutterMethodChannel(
name: "buzz/inline_photo_picker/\(viewId)",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "claimSelection" else {
result(FlutterMethodNotImplemented)
return
}
let paths = call.arguments as? [String] ?? []
guard let self, paths == self.selectedTemporaryPaths else {
result(false)
return
}
self.selectedTemporaryPaths = []
result(true)
}
containerView.backgroundColor = .clear
containerView.clipsToBounds = true
if #available(iOS 17.0, *) {
installPicker()
}
}
deinit {
selectionTask?.cancel()
Self.removeTemporaryFiles(selectedTemporaryPaths)
channel.setMethodCallHandler(nil)
pickerViewController?.willMove(toParent: nil)
pickerViewController?.view.removeFromSuperview()
pickerViewController?.removeFromParent()
}
func view() -> UIView {
containerView
}
@available(iOS 17.0, *)
private func installPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 0
configuration.selection = .continuousAndOrdered
configuration.preferredAssetRepresentationMode = .compatible
configuration.disabledCapabilities = [
.search,
.stagingArea,
.collectionNavigation,
.selectionActions,
]
configuration.edgesWithoutContentMargins = .all
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.backgroundColor = .clear
picker.view.translatesAutoresizingMaskIntoConstraints = false
if let parentViewController {
parentViewController.addChild(picker)
}
containerView.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
picker.view.topAnchor.constraint(equalTo: containerView.topAnchor),
picker.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
if parentViewController != nil {
picker.didMove(toParent: parentViewController)
}
pickerViewController = picker
containerView.layoutIfNeeded()
}
private func exportPickerResult(_ result: PHPickerResult) async throws -> String {
let provider = result.itemProvider
guard
let typeIdentifier = provider.registeredTypeIdentifiers.first(where: {
guard let type = UTType($0) else { return false }
return type.conforms(to: .image)
})
else {
throw InlinePhotoPickerError.unsupportedImage
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) {
sourceURL,
error in
if let error {
continuation.resume(throwing: error)
return
}
guard let sourceURL else {
continuation.resume(throwing: InlinePhotoPickerError.missingFile)
return
}
do {
let fileExtension =
sourceURL.pathExtension.isEmpty
? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg")
: sourceURL.pathExtension
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.copyItem(
at: sourceURL,
to: destinationURL
)
continuation.resume(returning: destinationURL.path)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func removeTemporaryFiles(_ paths: [String]) {
for path in paths where !path.isEmpty {
try? FileManager.default.removeItem(atPath: path)
}
}
}
extension InlinePhotoPickerPlatformView: PHPickerViewControllerDelegate {
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
selectionGeneration += 1
let generation = selectionGeneration
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedTemporaryPaths)
selectedTemporaryPaths = []
channel.invokeMethod(
"selectionCountChanged",
arguments: results.count
)
guard !results.isEmpty else {
channel.invokeMethod("selectionDidChange", arguments: [String]())
return
}
selectionTask = Task { [weak self] in
guard let self else { return }
var paths: [String] = []
do {
for result in results {
try Task.checkCancellation()
paths.append(try await self.exportPickerResult(result))
}
try Task.checkCancellation()
await MainActor.run {
guard generation == self.selectionGeneration else {
Self.removeTemporaryFiles(paths)
return
}
self.selectedTemporaryPaths = paths
self.selectionTask = nil
self.channel.invokeMethod("selectionDidChange", arguments: paths)
}
} catch is CancellationError {
Self.removeTemporaryFiles(paths)
} catch {
Self.removeTemporaryFiles(paths)
await MainActor.run {
guard generation == self.selectionGeneration else { return }
self.selectionTask = nil
self.channel.invokeMethod(
"didFail",
arguments: "Unable to prepare the selected photos."
)
}
}
}
}
}
private enum InlinePhotoPickerError: Error {
case missingFile
case unsupportedImage
}
+199
View File
@@ -0,0 +1,199 @@
import Foundation
import UIKit
private enum MediaSanitizationError: Error {
case invalidPng
case invalidJpeg
}
enum MediaSanitizer {
private static let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
private static let allowedPngAncillaryChunks: Set<String> = [
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
]
static func sanitizeImage(_ image: UIImage, mimeType: String) throws -> Data? {
switch mimeType {
case "image/png":
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
return try scrubPng(encoded)
case "image/jpeg":
return try encodeJpeg(image)
case "image/webp":
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
return try scrubPng(encoded)
default:
return nil
}
}
static func encodeJpeg(_ image: UIImage) throws -> Data? {
guard
let image = renderInSRGB(image),
let encoded = image.jpegData(compressionQuality: 1.0)
else {
return nil
}
return try scrubJpeg(encoded)
}
private static func renderInSRGB(_ image: UIImage) -> UIImage? {
guard image.size.width > 0, image.size.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
format.preferredRange = .standard
return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: image.size))
}
}
static func scrubPng(_ data: Data) throws -> Data {
let data = Data(data)
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
throw MediaSanitizationError.invalidPng
}
var output = pngSignature
var offset = pngSignature.count
while offset < data.count {
guard data.count - offset >= 12 else {
throw MediaSanitizationError.invalidPng
}
let payloadLengthValue = try readUInt32BigEndian(data, at: offset)
guard
let payloadLength = Int(exactly: payloadLengthValue),
payloadLength <= data.count - offset - 12
else {
throw MediaSanitizationError.invalidPng
}
let chunkLength = payloadLength + 12
let typeStart = offset + 4
let typeEnd = typeStart + 4
let typeBytes = data[typeStart..<typeEnd]
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
throw MediaSanitizationError.invalidPng
}
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
if !isAncillary || allowedPngAncillaryChunks.contains(type) {
output.append(data[offset..<(offset + chunkLength)])
}
offset += chunkLength
if type == "IEND" {
return output
}
}
throw MediaSanitizationError.invalidPng
}
static func scrubJpeg(_ data: Data) throws -> Data {
let data = Data(data)
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
throw MediaSanitizationError.invalidJpeg
}
var output = Data([0xFF, 0xD8])
var offset = 2
var inScan = false
while offset < data.count {
if inScan, data[offset] != 0xFF {
let nextMarker = data[offset...].firstIndex(of: 0xFF) ?? data.endIndex
output.append(data[offset..<nextMarker])
offset = nextMarker
continue
}
guard data[offset] == 0xFF else {
throw MediaSanitizationError.invalidJpeg
}
let markerStart = offset
while offset < data.count, data[offset] == 0xFF {
offset += 1
}
guard offset < data.count else {
throw MediaSanitizationError.invalidJpeg
}
let marker = data[offset]
offset += 1
if inScan, marker == 0x00 {
output.append(data[markerStart..<offset])
continue
}
if (0xD0...0xD7).contains(marker) || marker == 0x01 {
output.append(data[markerStart..<offset])
continue
}
if marker == 0xD9 {
output.append(data[markerStart..<offset])
return output
}
guard marker != 0xD8, data.count - offset >= 2 else {
throw MediaSanitizationError.invalidJpeg
}
let segmentLength = try readUInt16BigEndian(data, at: offset)
guard segmentLength >= 2, Int(segmentLength) <= data.count - offset else {
throw MediaSanitizationError.invalidJpeg
}
let segmentEnd = offset + Int(segmentLength)
if shouldKeepJpegSegment(marker, data: data, payload: (offset + 2)..<segmentEnd) {
output.append(data[markerStart..<segmentEnd])
}
offset = segmentEnd
inScan = marker == 0xDA
}
throw MediaSanitizationError.invalidJpeg
}
private static func shouldKeepJpegSegment(
_ marker: UInt8,
data: Data,
payload: Range<Int>
) -> Bool {
switch marker {
case 0xE0:
guard
payload.count >= 14,
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
0x4A, 0x46, 0x49, 0x46, 0x00,
])
else {
return false
}
let thumbnailWidth = Int(data[payload.lowerBound + 12])
let thumbnailHeight = Int(data[payload.lowerBound + 13])
return payload.count == 14 + 3 * thumbnailWidth * thumbnailHeight
case 0xEE:
return payload.count == 12
&& data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
0x41, 0x64, 0x6F, 0x62, 0x65,
])
case 0xE1...0xED, 0xEF, 0xFE:
return false
default:
return true
}
}
private static func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
guard data.count - offset >= 2 else {
throw MediaSanitizationError.invalidJpeg
}
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
}
private static func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
guard data.count - offset >= 4 else {
throw MediaSanitizationError.invalidPng
}
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,431 @@
import CoreText
import Flutter
import UIKit
enum NativeAttachmentExpandedSurfaceBehavior {
@MainActor
static func dismissKeyboard(in window: UIWindow?) {
window?.endEditing(true)
}
static func keyboardOverlap(
containerBounds: CGRect,
keyboardLayoutFrame: CGRect
) -> CGFloat {
guard
!keyboardLayoutFrame.isNull,
!keyboardLayoutFrame.isInfinite,
keyboardLayoutFrame.minY < containerBounds.maxY
else {
return 0
}
return max(0, containerBounds.maxY - keyboardLayoutFrame.minY)
}
}
enum NativeAttachmentPopoverAnchorLayout {
static let expandedVerticalOffset: CGFloat = 40
static func sourceRect(
anchorBounds: CGRect,
keyboardDismissalOffset: CGFloat,
isExpanded: Bool
) -> CGRect {
anchorBounds.offsetBy(
dx: 0,
dy: keyboardDismissalOffset
+ (isExpanded ? expandedVerticalOffset : 0)
)
}
}
enum NativeAttachmentPopoverPresentationLayout {
static func keyboardDismissalOffset(
sourceRect: CGRect,
containerBounds: CGRect,
safeAreaInsets: UIEdgeInsets,
keyboardLayoutFrame: CGRect,
menuHeight: CGFloat
) -> CGFloat {
let keyboardOverlap =
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
containerBounds: containerBounds,
keyboardLayoutFrame: keyboardLayoutFrame
)
guard keyboardOverlap > 0 else { return 0 }
let availableHeight =
sourceRect.minY - (containerBounds.minY + safeAreaInsets.top)
return availableHeight >= menuHeight ? 0 : keyboardOverlap
}
static func sourceRect(
_ sourceRect: CGRect,
keyboardDismissalOffset: CGFloat
) -> CGRect {
sourceRect.offsetBy(dx: 0, dy: keyboardDismissalOffset)
}
}
final class NativeAttachmentPopoverCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private weak var sourceAnchorView: UIView?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/native_attachment_popover",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "isSupported":
if #available(iOS 26.0, *) {
result(true)
} else {
result(false)
}
case "present":
guard
let arguments = call.arguments as? [String: Any],
let x = arguments["x"] as? NSNumber,
let y = arguments["y"] as? NSNumber,
let width = arguments["width"] as? NSNumber,
let height = arguments["height"] as? NSNumber
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected the attachment trigger bounds.",
details: nil
)
)
return
}
let sourceRect = CGRect(
x: CGFloat(truncating: x),
y: CGFloat(truncating: y),
width: CGFloat(truncating: width),
height: CGFloat(truncating: height)
)
DispatchQueue.main.async { [weak self] in
result(self?.presentPopover(sourceRect: sourceRect) ?? false)
}
case "dismiss":
DispatchQueue.main.async { [weak self] in
if #available(iOS 26.0, *),
let controller =
self?.presentedController
as? NativeAttachmentPopoverViewController
{
controller.dismissAndNotify()
} else {
self?.presentedController?.dismiss(animated: true)
}
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
@MainActor
private func presentPopover(sourceRect: CGRect) -> Bool {
guard #available(iOS 26.0, *) else { return false }
guard presentedController == nil else { return true }
let rootViewController =
parentViewController ?? activeWindowRootViewController()
guard let presenter = topViewController(from: rootViewController) else {
return false
}
let sourceView = presenter.view
var convertedRect: CGRect
if let window = sourceView?.window {
convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect
} else {
convertedRect = sourceRect
}
if let sourceView {
let keyboardDismissalOffset =
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
sourceRect: convertedRect,
containerBounds: sourceView.bounds,
safeAreaInsets: sourceView.safeAreaInsets,
keyboardLayoutFrame: sourceView.keyboardLayoutGuide.layoutFrame,
menuHeight: NativeAttachmentMenuLayout.size(
compatibleWith: sourceView.traitCollection
).height
)
if keyboardDismissalOffset > 0 {
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(
in: sourceView.window
)
convertedRect =
NativeAttachmentPopoverPresentationLayout.sourceRect(
convertedRect,
keyboardDismissalOffset: keyboardDismissalOffset
)
}
}
let anchorView = makeSourceAnchor(frame: convertedRect)
sourceView?.addSubview(anchorView)
sourceAnchorView = anchorView
let availableWidth = max(
320,
min(
(sourceView?.bounds.width ?? UIScreen.main.bounds.width) - 24,
430
)
)
let controller = NativeAttachmentPopoverViewController(
channel: channel,
expandedWidth: availableWidth
)
controller.modalPresentationStyle = .popover
controller.preferredTransition = .zoom { [weak anchorView] _ in
anchorView
}
controller.onDismiss = { [weak self] in
self?.presentedController = nil
self?.sourceAnchorView?.removeFromSuperview()
self?.channel.invokeMethod("dismissed", arguments: nil)
}
guard let popover = controller.popoverPresentationController else {
anchorView.removeFromSuperview()
return false
}
popover.sourceView = anchorView
popover.sourceRect = anchorView.bounds
popover.permittedArrowDirections = [.down]
popover.backgroundColor = .clear
popover.delegate = controller
presentedController = controller
presenter.present(controller, animated: true)
return true
}
@MainActor
private func makeSourceAnchor(frame: CGRect) -> UIView {
let anchor = UIView(frame: frame)
anchor.isUserInteractionEnabled = false
anchor.accessibilityElementsHidden = true
anchor.backgroundColor = .clear
anchor.layer.cornerRadius = min(frame.width, frame.height) / 2
anchor.layer.cornerCurve = .continuous
return anchor
}
@MainActor
private func activeWindowRootViewController() -> UIViewController? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
}
@MainActor
private func topViewController(
from viewController: UIViewController?
) -> UIViewController? {
if let presented = viewController?.presentedViewController {
return topViewController(from: presented)
}
if let navigation = viewController as? UINavigationController {
return topViewController(from: navigation.visibleViewController)
}
if let tab = viewController as? UITabBarController {
return topViewController(from: tab.selectedViewController)
}
return viewController
}
}
enum NativeAttachmentMenuLayout {
static let itemCount: CGFloat = 4
static let contentPadding: CGFloat = 16
static let minimumItemHeight: CGFloat = 52
static let itemSpacing: CGFloat = 8
static let itemVerticalPadding: CGFloat = 8
static let maximumHeight: CGFloat = 372
static let width: CGFloat = 216
static let labelTextStyle: UIFont.TextStyle = .title3
static func itemHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
let labelHeight = NativeAttachmentMenuTypography.font(
forTextStyle: labelTextStyle,
compatibleWith: traitCollection
).lineHeight
return max(
minimumItemHeight,
ceil(labelHeight + (itemVerticalPadding * 2))
)
}
static func itemsHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
(itemHeight(compatibleWith: traitCollection) * itemCount)
+ (itemSpacing * (itemCount - 1))
}
static func contentHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
(contentPadding * 2) + itemsHeight(compatibleWith: traitCollection)
}
static func size(
compatibleWith traitCollection: UITraitCollection,
maximumHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight
) -> CGSize {
CGSize(
width: width,
height: min(
contentHeight(compatibleWith: traitCollection),
maximumHeight
)
)
}
}
enum NativeAttachmentMenuTypography {
static let interPostScriptName = "InterVariable"
private static let registeredInter: Bool = {
let fontURL = Bundle.main.bundleURL
.appendingPathComponent("Frameworks")
.appendingPathComponent("App.framework")
.appendingPathComponent("flutter_assets")
.appendingPathComponent("assets")
.appendingPathComponent("fonts")
.appendingPathComponent("InterVariable.ttf")
guard FileManager.default.fileExists(atPath: fontURL.path) else {
return false
}
return CTFontManagerRegisterFontsForURL(
fontURL as CFURL,
.process,
nil
)
}()
static func font(
forTextStyle textStyle: UIFont.TextStyle,
compatibleWith traitCollection: UITraitCollection? = nil
) -> UIFont {
_ = registeredInter
let scaledPointSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(
for: 20,
compatibleWith: traitCollection
)
let preferredFont = UIFont.preferredFont(
forTextStyle: textStyle,
compatibleWith: traitCollection
)
guard
let interFont = UIFont(
name: interPostScriptName,
size: scaledPointSize
)
else {
return preferredFont
}
return interFont
}
}
enum NativeAttachmentPopoverStyle {
static let cornerRadius: CGFloat = 20
static let shadowOpacity: Float = 0.18
static let shadowRadius: CGFloat = 12
static let shadowOffset = CGSize(width: 0, height: 6)
static let borderWidth: CGFloat = 1
}
func makeNativeAttachmentMenuButton(
title: String,
symbol: String,
action: @escaping () -> Void
) -> UIButton {
let button = UIButton(
primaryAction: UIAction { _ in
UISelectionFeedbackGenerator().selectionChanged()
action()
}
)
button.accessibilityLabel = title
let symbolConfiguration = UIImage.SymbolConfiguration(
pointSize: 18,
weight: .regular
)
let iconView = UIImageView(
image: UIImage(
systemName: symbol,
withConfiguration: symbolConfiguration
)
)
iconView.tintColor = .label
iconView.contentMode = .center
iconView.translatesAutoresizingMaskIntoConstraints = false
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.textColor = .label
titleLabel.font = NativeAttachmentMenuTypography.font(
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .left
titleLabel.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(iconView)
button.addSubview(titleLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(
equalTo: button.leadingAnchor,
constant: 8
),
iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 26),
titleLabel.leadingAnchor.constraint(
equalTo: iconView.trailingAnchor,
constant: 12
),
titleLabel.trailingAnchor.constraint(
equalTo: button.trailingAnchor,
constant: -8
),
titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
button.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.62 : 1
}
return button
}
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}