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>
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 918 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||