diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml
index 8ee23ef..17c3560 100644
--- a/.github/workflows/build-ios.yml
+++ b/.github/workflows/build-ios.yml
@@ -79,6 +79,9 @@ jobs:
get-task-allow
+ application-identifierru.komet.app
+ keychain-access-groups
+ ru.komet.app
PLIST
diff --git a/.github/workflows/flutter-dev.yml b/.github/workflows/flutter-dev.yml
index c3a09fa..92e7052 100644
--- a/.github/workflows/flutter-dev.yml
+++ b/.github/workflows/flutter-dev.yml
@@ -71,3 +71,32 @@ jobs:
- name: Build Android APK
run: flutter build apk --release --flavor komet
+
+ build-ios:
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+ with:
+ submodules: recursive
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.44.3'
+ channel: 'stable'
+ cache: true
+
+ - name: Setup Rust
+ uses: ./.github/actions/setup-rust
+ with:
+ targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Build iOS (no codesign)
+ run: |
+ flutter config --no-enable-swift-package-manager
+ flutter build ios --release --no-codesign
diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml
index 6471534..573b309 100644
--- a/.github/workflows/release-dev.yml
+++ b/.github/workflows/release-dev.yml
@@ -246,6 +246,9 @@ jobs:
platform-application
get-task-allow
+ application-identifierru.komet.app
+ keychain-access-groups
+ ru.komet.app
com.apple.private.security.no-container
diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml
index 7827214..d6ea1b4 100644
--- a/.github/workflows/release-main.yml
+++ b/.github/workflows/release-main.yml
@@ -233,6 +233,9 @@ jobs:
platform-application
get-task-allow
+ application-identifierru.komet.app
+ keychain-access-groups
+ ru.komet.app
com.apple.private.security.no-container
diff --git a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt
index e8fb5aa..ffff9c4 100644
--- a/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt
+++ b/android/app/src/main/kotlin/ru/komet/app/KometFcmService.kt
@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.content.pm.ShortcutInfo
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
@@ -275,7 +276,7 @@ class KometNotifier(private val ctx: Context) {
.setIntent(intent)
.setPerson(person)
.setIcon(person.icon)
- .setCategories(setOf(ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION))
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setLocusId(LocusIdCompat(id))
.build()
ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut)
diff --git a/ios/Podfile b/ios/Podfile
index fc81fe8..8d2dd65 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -69,8 +69,68 @@ target 'Runner' do
end
end
+# libopus already ships with ogg_opus_player as a static xcframework. Nothing in
+# the app references its encoder entry points, so the linker would drop them and
+# `DynamicLibrary.process()` (OpusOggEncoder) would find nothing. Force-loading
+# the slice keeps the whole library, which is what voice-message encoding needs.
+OPUS_FORCE_LOAD =
+ '-force_load "${PODS_XCFRAMEWORKS_BUILD_DIR}/ogg_opus_player/libopus.a"'.freeze
+
+# permission_handler compiles every permission handler unless told otherwise;
+# the unused ones reference APIs that make App Store review ask for usage
+# descriptions the app has no reason to declare.
+PERMISSION_MACROS = %w[
+ PERMISSION_CAMERA=1
+ PERMISSION_MICROPHONE=1
+ PERMISSION_PHOTOS=1
+ PERMISSION_PHOTOS_ADD_ONLY=1
+ PERMISSION_LOCATION=1
+ PERMISSION_LOCATION_WHENINUSE=1
+ PERMISSION_CONTACTS=1
+ PERMISSION_NOTIFICATIONS=1
+ PERMISSION_LOCATION_ALWAYS=0
+ PERMISSION_MEDIA_LIBRARY=0
+ PERMISSION_EVENTS=0
+ PERMISSION_EVENTS_FULL_ACCESS=0
+ PERMISSION_REMINDERS=0
+ PERMISSION_SPEECH_RECOGNIZER=0
+ PERMISSION_SENSORS=0
+ PERMISSION_BLUETOOTH=0
+ PERMISSION_APP_TRACKING_TRANSPARENCY=0
+ PERMISSION_CRITICAL_ALERTS=0
+ PERMISSION_ASSISTANT=0
+].freeze
+
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
+
+ next unless target.name == 'permission_handler_apple'
+
+ target.build_configurations.each do |config|
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] =
+ ['$(inherited)'] + PERMISSION_MACROS
+ end
+ end
+
+ installer.aggregate_targets.each do |target|
+ next unless target.name == 'Pods-Runner'
+
+ %w[debug profile release].each do |name|
+ xcconfig = target.xcconfig_path(name)
+ next unless File.exist?(xcconfig)
+
+ contents = File.read(xcconfig)
+ next if contents.include?('ogg_opus_player/libopus.a')
+
+ if contents =~ /^OTHER_LDFLAGS = .*$/
+ contents = contents.sub(/^OTHER_LDFLAGS = (.*)$/) do
+ "OTHER_LDFLAGS = #{Regexp.last_match(1)} #{OPUS_FORCE_LOAD}"
+ end
+ else
+ contents += "\nOTHER_LDFLAGS = $(inherited) #{OPUS_FORCE_LOAD}\n"
+ end
+ File.write(xcconfig, contents)
+ end
end
end
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index ffaa7a0..657cb8e 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -12,6 +12,9 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550001 /* KometVideo.swift */; };
+ AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */; };
+ AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550003 /* KometNotifications.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -56,6 +59,10 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ AA11BB22CC33DD44EE550001 /* KometVideo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideo.swift; sourceTree = ""; };
+ AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideoNote.swift; sourceTree = ""; };
+ AA11BB22CC33DD44EE550003 /* KometNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometNotifications.swift; sourceTree = ""; };
+ AA11BB22CC33DD44EE550004 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
@@ -157,6 +164,10 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ AA11BB22CC33DD44EE550001 /* KometVideo.swift */,
+ AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */,
+ AA11BB22CC33DD44EE550003 /* KometNotifications.swift */,
+ AA11BB22CC33DD44EE550004 /* Runner.entitlements */,
);
path = Runner;
sourceTree = "";
@@ -396,6 +407,9 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */,
+ AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */,
+ AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -495,6 +509,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -677,6 +692,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -699,6 +715,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 948ca04..fecc9b1 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -1,50 +1,152 @@
import Flutter
import UIKit
+final class KometStreamHandler: NSObject, FlutterStreamHandler {
+ private let onSink: (FlutterEventSink?) -> Void
+
+ init(onSink: @escaping (FlutterEventSink?) -> Void) {
+ self.onSink = onSink
+ }
+
+ func onListen(
+ withArguments arguments: Any?,
+ eventSink events: @escaping FlutterEventSink
+ ) -> FlutterError? {
+ onSink(events)
+ return nil
+ }
+
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
+ onSink(nil)
+ return nil
+ }
+}
+
@main
@objc class AppDelegate: FlutterAppDelegate {
+ private var channels: [FlutterMethodChannel] = []
+ private var eventChannels: [FlutterEventChannel] = []
+ private var streamHandlers: [KometStreamHandler] = []
+ private var videoNote: KometVideoNote?
+
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
+ KometNotifications.shared.start()
- let controller = window?.rootViewController as? FlutterViewController
- if let messenger = controller?.binaryMessenger {
- let channel = FlutterMethodChannel(
- name: "ru.komet.app/app_icon",
- binaryMessenger: messenger
- )
- channel.setMethodCallHandler { (call, result) in
- guard call.method == "setAppIcon" else {
- result(FlutterMethodNotImplemented)
- return
- }
- let args = call.arguments as? [String: Any]
- let name = args?["name"] as? String
- let iconName: String? = (name == "DefaultIcon") ? nil : name
- if !UIApplication.shared.supportsAlternateIcons {
- result(FlutterError(
- code: "UNSUPPORTED",
- message: "Alternate icons are not supported",
- details: nil
- ))
- return
- }
- UIApplication.shared.setAlternateIconName(iconName) { error in
- if let error = error {
- result(FlutterError(
- code: "APPLY_FAILED",
- message: error.localizedDescription,
- details: nil
- ))
- } else {
- result(nil)
- }
- }
- }
+ if let controller = window?.rootViewController as? FlutterViewController {
+ let messenger = controller.binaryMessenger
+ registerAppIcon(messenger)
+ registerVideo(messenger)
+ registerVideoNote(messenger)
+ registerNotifications(messenger)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ private func method(_ name: String, _ messenger: FlutterBinaryMessenger,
+ _ handler: @escaping FlutterMethodCallHandler) {
+ let channel = FlutterMethodChannel(name: name, binaryMessenger: messenger)
+ channel.setMethodCallHandler(handler)
+ channels.append(channel)
+ }
+
+ private func events(_ name: String, _ messenger: FlutterBinaryMessenger,
+ _ onSink: @escaping (FlutterEventSink?) -> Void) {
+ let handler = KometStreamHandler(onSink: onSink)
+ let channel = FlutterEventChannel(name: name, binaryMessenger: messenger)
+ channel.setStreamHandler(handler)
+ streamHandlers.append(handler)
+ eventChannels.append(channel)
+ }
+
+ private func registerAppIcon(_ messenger: FlutterBinaryMessenger) {
+ method("ru.komet.app/app_icon", messenger) { call, result in
+ guard call.method == "setAppIcon" else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ let name = (call.arguments as? [String: Any])?["name"] as? String
+ let iconName: String? = (name == "DefaultIcon") ? nil : name
+ guard UIApplication.shared.supportsAlternateIcons else {
+ result(FlutterError(code: "UNSUPPORTED",
+ message: "Alternate icons are not supported",
+ details: nil))
+ return
+ }
+ UIApplication.shared.setAlternateIconName(iconName) { error in
+ if let error = error {
+ result(FlutterError(code: "APPLY_FAILED",
+ message: error.localizedDescription,
+ details: nil))
+ } else {
+ result(nil)
+ }
+ }
+ }
+ }
+
+ private func registerVideo(_ messenger: FlutterBinaryMessenger) {
+ method("ru.komet.app/video", messenger) { call, result in
+ KometVideo.shared.handle(call, result: result)
+ }
+ }
+
+ private func registerVideoNote(_ messenger: FlutterBinaryMessenger) {
+ guard let textures = registrar(forPlugin: "KometVideoNote")?.textures() else { return }
+
+ method("ru.komet.app/video_note", messenger) { [weak self] call, result in
+ guard let self = self else { return }
+ switch call.method {
+ case "permission":
+ KometVideoNote.requestPermission(result)
+ case "init":
+ let args = call.arguments as? [String: Any] ?? [:]
+ self.videoNote?.dispose()
+ let recorder = KometVideoNote(registry: textures)
+ self.videoNote = recorder
+ recorder.initialize(
+ front: (args["front"] as? NSNumber)?.boolValue ?? true,
+ edge: (args["size"] as? NSNumber)?.intValue ?? 480,
+ fps: (args["fps"] as? NSNumber)?.intValue ?? 30,
+ result: result)
+ case "start":
+ self.withRecorder(result) { $0.start(result: result) }
+ case "switch":
+ self.withRecorder(result) { $0.switchCamera(result: result) }
+ case "torch":
+ let on = ((call.arguments as? [String: Any])?["on"] as? NSNumber)?.boolValue ?? false
+ self.withRecorder(result) { $0.setTorch(on: on, result: result) }
+ case "stop":
+ self.withRecorder(result) { $0.stop(result: result) }
+ case "dispose":
+ self.videoNote?.dispose()
+ self.videoNote = nil
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+
+ private func withRecorder(_ result: @escaping FlutterResult,
+ _ body: (KometVideoNote) -> Void) {
+ guard let recorder = videoNote else {
+ result(FlutterError(code: "NOT_READY", message: "recorder not initialized", details: nil))
+ return
+ }
+ body(recorder)
+ }
+
+ private func registerNotifications(_ messenger: FlutterBinaryMessenger) {
+ method("ru.komet.app/notifications", messenger) { call, result in
+ KometNotifications.shared.handle(call, result: result)
+ }
+ events("ru.komet.app/notification_events", messenger) { sink in
+ KometNotifications.shared.attach(sink)
+ }
+ }
}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index ee5c26e..7bd3efc 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -84,5 +84,31 @@
+ CFBundleLocalizations
+
+ ru
+ en
+
+ UIBackgroundModes
+
+ audio
+
+ UIFileSharingEnabled
+
+ LSSupportsOpeningDocumentsInPlace
+
+ ITSAppUsesNonExemptEncryption
+
+ LSApplicationQueriesSchemes
+
+ tel
+ telprompt
+ sms
+ mailto
+ maps
+ comgooglemaps
+ yandexmaps
+ yandexnavi
+
diff --git a/ios/Runner/KometNotifications.swift b/ios/Runner/KometNotifications.swift
new file mode 100644
index 0000000..c82c0bc
--- /dev/null
+++ b/ios/Runner/KometNotifications.swift
@@ -0,0 +1,105 @@
+import Flutter
+import UIKit
+import UserNotifications
+
+final class KometNotifications: NSObject {
+ static let shared = KometNotifications()
+
+ private static let chatKeys = ["komet_chat", "chatId", "chat_id"]
+
+ private var sink: FlutterEventSink?
+ private var pendingChatId: Int64 = 0
+ private var activeChatId: Int64 = 0
+
+ func start() {
+ UNUserNotificationCenter.current().delegate = self
+ }
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "consumeInitialChat":
+ let chatId = pendingChatId
+ pendingChatId = 0
+ result(chatId > 0 ? NSNumber(value: chatId) : nil)
+ case "setActiveChat":
+ activeChatId = Self.chatId(from: call.arguments)
+ dismissDelivered(chatId: activeChatId)
+ result(nil)
+ case "clearActiveChat":
+ activeChatId = 0
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ func attach(_ sink: FlutterEventSink?) {
+ self.sink = sink
+ }
+
+ func deliver(chatId: Int64) {
+ guard chatId > 0 else { return }
+ if let sink = sink {
+ sink(NSNumber(value: chatId))
+ } else {
+ pendingChatId = chatId
+ }
+ }
+
+ private func dismissDelivered(chatId: Int64) {
+ guard chatId > 0 else { return }
+ let center = UNUserNotificationCenter.current()
+ center.getDeliveredNotifications { delivered in
+ let identifiers = delivered
+ .filter { Self.chatId(from: $0.request.content.userInfo) == chatId }
+ .map { $0.request.identifier }
+ guard !identifiers.isEmpty else { return }
+ center.removeDeliveredNotifications(withIdentifiers: identifiers)
+ }
+ }
+
+ private static func chatId(from raw: Any?) -> Int64 {
+ if let number = raw as? NSNumber { return number.int64Value }
+ if let text = raw as? String { return Int64(text) ?? 0 }
+ if let map = raw as? [AnyHashable: Any] {
+ for key in chatKeys {
+ if let value = map[key], let parsed = optionalChatId(value) { return parsed }
+ }
+ }
+ return 0
+ }
+
+ private static func optionalChatId(_ raw: Any) -> Int64? {
+ if let number = raw as? NSNumber { return number.int64Value }
+ if let text = raw as? String { return Int64(text) }
+ return nil
+ }
+}
+
+extension KometNotifications: UNUserNotificationCenterDelegate {
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ let chatId = Self.chatId(from: notification.request.content.userInfo)
+ if chatId > 0, chatId == activeChatId {
+ completionHandler([])
+ return
+ }
+ if #available(iOS 14.0, *) {
+ completionHandler([.banner, .list, .sound, .badge])
+ } else {
+ completionHandler([.alert, .sound, .badge])
+ }
+ }
+
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void
+ ) {
+ deliver(chatId: Self.chatId(from: response.notification.request.content.userInfo))
+ completionHandler()
+ }
+}
diff --git a/ios/Runner/KometVideo.swift b/ios/Runner/KometVideo.swift
new file mode 100644
index 0000000..9ec0bd8
--- /dev/null
+++ b/ios/Runner/KometVideo.swift
@@ -0,0 +1,333 @@
+import AVFoundation
+import CoreImage
+import Flutter
+import UIKit
+
+private struct VideoExportSpec {
+ let input: String
+ let output: String
+ let startMs: Int?
+ let endMs: Int?
+ let removeAudio: Bool
+ let rotationDegrees: Double
+ let flipH: Bool
+ let crop: [Double]?
+ let outWidth: Int
+ let outHeight: Int
+ let rgbMatrix: [Double]?
+ let overlay: String?
+ let centerSquare: Bool
+
+ init?(_ arguments: Any?) {
+ guard let args = arguments as? [String: Any],
+ let input = args["input"] as? String,
+ let output = args["output"] as? String else { return nil }
+ self.input = input
+ self.output = output
+ startMs = (args["startMs"] as? NSNumber)?.intValue
+ endMs = (args["endMs"] as? NSNumber)?.intValue
+ removeAudio = (args["removeAudio"] as? NSNumber)?.boolValue ?? false
+ rotationDegrees = (args["rotationDegrees"] as? NSNumber)?.doubleValue ?? 0
+ flipH = (args["flipH"] as? NSNumber)?.boolValue ?? false
+ crop = (args["crop"] as? [NSNumber])?.map { $0.doubleValue }
+ outWidth = (args["outWidth"] as? NSNumber)?.intValue ?? 0
+ outHeight = (args["outHeight"] as? NSNumber)?.intValue ?? 0
+ rgbMatrix = (args["rgbMatrix"] as? [NSNumber])?.map { $0.doubleValue }
+ overlay = args["overlay"] as? String
+ centerSquare = false
+ }
+
+ init(input: String, output: String, edge: Int) {
+ self.input = input
+ self.output = output
+ startMs = nil
+ endMs = nil
+ removeAudio = false
+ rotationDegrees = 0
+ flipH = false
+ crop = nil
+ outWidth = edge
+ outHeight = edge
+ rgbMatrix = nil
+ overlay = nil
+ centerSquare = true
+ }
+}
+
+final class KometVideo {
+ static let shared = KometVideo()
+
+ private let queue = DispatchQueue(label: "ru.komet.app.video", qos: .userInitiated)
+ private var session: AVAssetExportSession?
+ private var cancelled = false
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "probe":
+ probe(call.arguments, result)
+ case "frames":
+ frames(call.arguments, result)
+ case "cropSquare":
+ cropSquare(call.arguments, result)
+ case "edit":
+ guard let spec = VideoExportSpec(call.arguments) else {
+ result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil))
+ return
+ }
+ export(spec) { ok in result(NSNumber(value: ok)) }
+ case "editProgress":
+ let value = session.map { Int(($0.progress * 100).rounded()) } ?? -1
+ result(NSNumber(value: value))
+ case "editCancel":
+ cancelled = true
+ session?.cancelExport()
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private func probe(_ arguments: Any?, _ result: @escaping FlutterResult) {
+ guard let args = arguments as? [String: Any], let input = args["input"] as? String else {
+ result(FlutterError(code: "BAD_ARGS", message: "input required", details: nil))
+ return
+ }
+ queue.async {
+ let asset = AVURLAsset(url: URL(fileURLWithPath: input))
+ guard let track = asset.tracks(withMediaType: .video).first else {
+ Self.reply(result, nil)
+ return
+ }
+ let size = track.naturalSize.applying(track.preferredTransform)
+ let seconds = CMTimeGetSeconds(asset.duration)
+ let durationMs = seconds.isFinite && seconds > 0 ? Int((seconds * 1000).rounded()) : 0
+ let fps = Double(track.nominalFrameRate)
+ let payload: [String: Any] = [
+ "width": Int(abs(size.width).rounded()),
+ "height": Int(abs(size.height).rounded()),
+ "durationMs": durationMs,
+ "fps": fps > 0 ? fps : 30.0,
+ "hasAudio": !asset.tracks(withMediaType: .audio).isEmpty,
+ ]
+ Self.reply(result, payload)
+ }
+ }
+
+ private func frames(_ arguments: Any?, _ result: @escaping FlutterResult) {
+ guard let args = arguments as? [String: Any],
+ let input = args["input"] as? String,
+ let times = args["times"] as? [NSNumber] else {
+ result(FlutterError(code: "BAD_ARGS", message: "input/times required", details: nil))
+ return
+ }
+ let edge = (args["size"] as? NSNumber)?.intValue ?? 256
+ let precise = (args["precise"] as? NSNumber)?.boolValue ?? false
+ queue.async {
+ let asset = AVURLAsset(url: URL(fileURLWithPath: input))
+ let generator = AVAssetImageGenerator(asset: asset)
+ generator.appliesPreferredTrackTransform = true
+ generator.maximumSize = CGSize(width: edge, height: edge)
+ if precise {
+ generator.requestedTimeToleranceBefore = .zero
+ generator.requestedTimeToleranceAfter = .zero
+ }
+ var output: [Any] = []
+ for time in times {
+ let at = CMTime(value: CMTimeValue(time.int64Value), timescale: 1000)
+ guard let cgImage = try? generator.copyCGImage(at: at, actualTime: nil),
+ let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else {
+ output.append(NSNull())
+ continue
+ }
+ output.append(FlutterStandardTypedData(bytes: data))
+ }
+ Self.reply(result, output)
+ }
+ }
+
+ private func cropSquare(_ arguments: Any?, _ result: @escaping FlutterResult) {
+ guard let args = arguments as? [String: Any],
+ let input = args["input"] as? String,
+ let output = args["output"] as? String else {
+ result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil))
+ return
+ }
+ let edge = (args["size"] as? NSNumber)?.intValue ?? 480
+ export(VideoExportSpec(input: input, output: output, edge: edge)) { ok in
+ if ok {
+ result(output)
+ } else {
+ result(FlutterError(code: "TRANSCODE_FAILED", message: "export failed", details: nil))
+ }
+ }
+ }
+
+ private func export(_ spec: VideoExportSpec, completion: @escaping (Bool) -> Void) {
+ queue.async {
+ self.cancelled = false
+ let asset = AVURLAsset(url: URL(fileURLWithPath: spec.input))
+ guard let videoTrack = asset.tracks(withMediaType: .video).first else {
+ Self.reply { completion(false) }
+ return
+ }
+
+ let totalSeconds = CMTimeGetSeconds(asset.duration)
+ let start = CMTime(value: CMTimeValue(spec.startMs ?? 0), timescale: 1000)
+ let endMs = spec.endMs ?? (totalSeconds.isFinite ? Int((totalSeconds * 1000).rounded()) : 0)
+ let end = CMTime(value: CMTimeValue(max(endMs, spec.startMs ?? 0)), timescale: 1000)
+ let range = CMTimeRange(start: start, end: end)
+ guard range.duration.seconds > 0 else {
+ Self.reply { completion(false) }
+ return
+ }
+
+ let composition = AVMutableComposition()
+ guard let compositionVideo = composition.addMutableTrack(
+ withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
+ Self.reply { completion(false) }
+ return
+ }
+ do {
+ try compositionVideo.insertTimeRange(range, of: videoTrack, at: .zero)
+ } catch {
+ Self.reply { completion(false) }
+ return
+ }
+ compositionVideo.preferredTransform = videoTrack.preferredTransform
+
+ if !spec.removeAudio,
+ let audioTrack = asset.tracks(withMediaType: .audio).first,
+ let compositionAudio = composition.addMutableTrack(
+ withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
+ try? compositionAudio.insertTimeRange(range, of: audioTrack, at: .zero)
+ }
+
+ let natural = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
+ let outWidth = spec.outWidth > 0 ? spec.outWidth : Int(abs(natural.width).rounded())
+ let outHeight = spec.outHeight > 0 ? spec.outHeight : Int(abs(natural.height).rounded())
+ guard outWidth > 0, outHeight > 0 else {
+ Self.reply { completion(false) }
+ return
+ }
+
+ let transform = videoTrack.preferredTransform
+ let overlay = spec.overlay.flatMap { UIImage(contentsOfFile: $0) }.flatMap { CIImage(image: $0) }
+ let renderSize = CGSize(width: outWidth, height: outHeight)
+
+ let videoComposition = AVMutableVideoComposition(asset: composition) { request in
+ let image = Self.render(
+ request.sourceImage,
+ spec: spec,
+ transform: transform,
+ overlay: overlay,
+ renderSize: renderSize)
+ request.finish(with: image, context: nil)
+ }
+ videoComposition.renderSize = renderSize
+
+ let outputURL = URL(fileURLWithPath: spec.output)
+ try? FileManager.default.removeItem(at: outputURL)
+
+ guard let session = AVAssetExportSession(
+ asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
+ Self.reply { completion(false) }
+ return
+ }
+ session.outputURL = outputURL
+ session.outputFileType = .mp4
+ session.videoComposition = videoComposition
+ session.shouldOptimizeForNetworkUse = true
+ self.session = session
+
+ session.exportAsynchronously {
+ let ok = session.status == .completed && !self.cancelled
+ self.session = nil
+ if !ok { try? FileManager.default.removeItem(at: outputURL) }
+ Self.reply { completion(ok) }
+ }
+ }
+ }
+
+ private static func render(
+ _ source: CIImage,
+ spec: VideoExportSpec,
+ transform: CGAffineTransform,
+ overlay: CIImage?,
+ renderSize: CGSize
+ ) -> CIImage {
+ var image = normalized(source.transformed(by: transform))
+
+ if spec.flipH {
+ image = normalized(image.transformed(by: CGAffineTransform(scaleX: -1, y: 1)))
+ }
+ if abs(spec.rotationDegrees) > 0.01 {
+ let radians = CGFloat(spec.rotationDegrees * .pi / 180)
+ image = normalized(image.transformed(by: CGAffineTransform(rotationAngle: radians)))
+ }
+ if spec.centerSquare {
+ let extent = image.extent
+ let side = min(extent.width, extent.height)
+ image = normalized(image.cropped(to: CGRect(
+ x: extent.midX - side / 2,
+ y: extent.midY - side / 2,
+ width: side,
+ height: side)))
+ } else if let crop = spec.crop, crop.count == 4 {
+ let extent = image.extent
+ let left = CGFloat((crop[0] + 1) / 2)
+ let right = CGFloat((crop[1] + 1) / 2)
+ let bottom = CGFloat((1 - crop[2]) / 2)
+ let top = CGFloat((1 - crop[3]) / 2)
+ let rect = CGRect(
+ x: extent.minX + left * extent.width,
+ y: extent.minY + (1 - bottom) * extent.height,
+ width: max(1, (right - left) * extent.width),
+ height: max(1, (bottom - top) * extent.height))
+ image = normalized(image.cropped(to: rect))
+ }
+
+ let extent = image.extent
+ if extent.width > 0, extent.height > 0 {
+ image = image.transformed(by: CGAffineTransform(
+ scaleX: renderSize.width / extent.width,
+ y: renderSize.height / extent.height))
+ image = normalized(image)
+ }
+
+ if let matrix = spec.rgbMatrix, matrix.count == 16,
+ let filter = CIFilter(name: "CIColorMatrix") {
+ filter.setValue(image, forKey: kCIInputImageKey)
+ filter.setValue(vector(matrix, 0, 4, 8), forKey: "inputRVector")
+ filter.setValue(vector(matrix, 1, 5, 9), forKey: "inputGVector")
+ filter.setValue(vector(matrix, 2, 6, 10), forKey: "inputBVector")
+ filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector")
+ filter.setValue(vector(matrix, 12, 13, 14), forKey: "inputBiasVector")
+ if let output = filter.outputImage { image = output }
+ }
+
+ if let overlay = overlay {
+ image = overlay.composited(over: image)
+ }
+
+ return image.cropped(to: CGRect(origin: .zero, size: renderSize))
+ }
+
+ private static func vector(_ m: [Double], _ x: Int, _ y: Int, _ z: Int) -> CIVector {
+ CIVector(x: CGFloat(m[x]), y: CGFloat(m[y]), z: CGFloat(m[z]), w: 0)
+ }
+
+ private static func normalized(_ image: CIImage) -> CIImage {
+ let extent = image.extent
+ guard extent.origin != .zero else { return image }
+ return image.transformed(
+ by: CGAffineTransform(translationX: -extent.origin.x, y: -extent.origin.y))
+ }
+
+ private static func reply(_ result: @escaping FlutterResult, _ value: Any?) {
+ DispatchQueue.main.async { result(value) }
+ }
+
+ private static func reply(_ block: @escaping () -> Void) {
+ DispatchQueue.main.async(execute: block)
+ }
+}
diff --git a/ios/Runner/KometVideoNote.swift b/ios/Runner/KometVideoNote.swift
new file mode 100644
index 0000000..94d8a23
--- /dev/null
+++ b/ios/Runner/KometVideoNote.swift
@@ -0,0 +1,396 @@
+import AVFoundation
+import CoreImage
+import Flutter
+import UIKit
+
+final class KometVideoNoteTexture: NSObject, FlutterTexture {
+ private let lock = NSLock()
+ private var latest: CVPixelBuffer?
+
+ func push(_ buffer: CVPixelBuffer) {
+ lock.lock()
+ latest = buffer
+ lock.unlock()
+ }
+
+ func copyPixelBuffer() -> Unmanaged? {
+ lock.lock()
+ defer { lock.unlock() }
+ guard let buffer = latest else { return nil }
+ return Unmanaged.passRetained(buffer)
+ }
+}
+
+final class KometVideoNote: NSObject {
+ private let registry: FlutterTextureRegistry
+ private let queue = DispatchQueue(label: "ru.komet.app.videonote", qos: .userInitiated)
+ private let ciContext = CIContext(options: [.useSoftwareRenderer: false])
+
+ private let session = AVCaptureSession()
+ private let videoOutput = AVCaptureVideoDataOutput()
+ private let audioOutput = AVCaptureAudioDataOutput()
+ private let texture = KometVideoNoteTexture()
+
+ private var textureId: Int64 = 0
+ private var deviceInput: AVCaptureDeviceInput?
+ private var audioInput: AVCaptureDeviceInput?
+ private var position: AVCaptureDevice.Position = .front
+ private var edge: Int = 480
+ private var fps: Int = 30
+
+ private var pixelBufferPool: CVPixelBufferPool?
+ private var writer: AVAssetWriter?
+ private var writerVideo: AVAssetWriterInput?
+ private var writerAudio: AVAssetWriterInput?
+ private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
+ private var outputURL: URL?
+ private var recording = false
+ private var sessionStarted = false
+
+ init(registry: FlutterTextureRegistry) {
+ self.registry = registry
+ super.init()
+ }
+
+ static func requestPermission(_ result: @escaping FlutterResult) {
+ AVCaptureDevice.requestAccess(for: .video) { video in
+ guard video else {
+ DispatchQueue.main.async { result(NSNumber(value: false)) }
+ return
+ }
+ AVCaptureDevice.requestAccess(for: .audio) { audio in
+ DispatchQueue.main.async { result(NSNumber(value: audio)) }
+ }
+ }
+ }
+
+ func initialize(front: Bool, edge: Int, fps: Int, result: @escaping FlutterResult) {
+ self.position = front ? .front : .back
+ self.edge = max(16, edge)
+ self.fps = max(1, fps)
+
+ queue.async {
+ guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else {
+ Self.fail(result, "NO_PERMISSION", "camera permission required")
+ return
+ }
+ do {
+ try self.configureSession()
+ } catch {
+ Self.fail(result, "NO_CAMERA", error.localizedDescription)
+ return
+ }
+ self.session.startRunning()
+
+ DispatchQueue.main.async {
+ if self.textureId == 0 {
+ self.textureId = self.registry.register(self.texture)
+ }
+ result(["textureId": NSNumber(value: self.textureId), "hasFlash": self.hasTorch()])
+ }
+ }
+ }
+
+ func switchCamera(result: @escaping FlutterResult) {
+ queue.async {
+ self.position = self.position == .front ? .back : .front
+ do {
+ try self.configureSession()
+ } catch {
+ Self.fail(result, "NO_CAMERA", error.localizedDescription)
+ return
+ }
+ DispatchQueue.main.async { result(nil) }
+ }
+ }
+
+ func setTorch(on: Bool, result: @escaping FlutterResult) {
+ queue.async {
+ guard let device = self.deviceInput?.device, device.hasTorch else {
+ DispatchQueue.main.async { result(NSNumber(value: false)) }
+ return
+ }
+ var applied = false
+ if (try? device.lockForConfiguration()) != nil {
+ device.torchMode = on ? .on : .off
+ device.unlockForConfiguration()
+ applied = on
+ }
+ DispatchQueue.main.async { result(NSNumber(value: applied)) }
+ }
+ }
+
+ func start(result: @escaping FlutterResult) {
+ queue.async {
+ guard !self.recording else {
+ DispatchQueue.main.async { result(nil) }
+ return
+ }
+ do {
+ try self.prepareWriter()
+ } catch {
+ Self.fail(result, "START_FAILED", error.localizedDescription)
+ return
+ }
+ self.sessionStarted = false
+ self.recording = true
+ DispatchQueue.main.async { result(nil) }
+ }
+ }
+
+ func stop(result: @escaping FlutterResult) {
+ queue.async {
+ guard self.recording, let writer = self.writer else {
+ Self.fail(result, "NOT_RECORDING", "no active recording")
+ return
+ }
+ self.recording = false
+ self.writerVideo?.markAsFinished()
+ self.writerAudio?.markAsFinished()
+ let url = self.outputURL
+ writer.finishWriting {
+ let ok = writer.status == .completed
+ self.writer = nil
+ self.writerVideo = nil
+ self.writerAudio = nil
+ self.adaptor = nil
+ self.outputURL = nil
+ let path: String? = ok ? url?.path : nil
+ DispatchQueue.main.async {
+ result(path)
+ }
+ }
+ }
+ }
+
+ func dispose() {
+ queue.sync {
+ if self.recording {
+ self.recording = false
+ self.writerVideo?.markAsFinished()
+ self.writerAudio?.markAsFinished()
+ self.writer?.cancelWriting()
+ self.writer = nil
+ }
+ if self.session.isRunning { self.session.stopRunning() }
+ for input in self.session.inputs { self.session.removeInput(input) }
+ for output in self.session.outputs { self.session.removeOutput(output) }
+ self.deviceInput = nil
+ self.audioInput = nil
+ self.pixelBufferPool = nil
+ }
+ if textureId != 0 {
+ registry.unregisterTexture(textureId)
+ textureId = 0
+ }
+ }
+
+ private func hasTorch() -> Bool {
+ deviceInput?.device.hasTorch ?? false
+ }
+
+ private func configureSession() throws {
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+
+ if let existing = deviceInput {
+ session.removeInput(existing)
+ deviceInput = nil
+ }
+
+ guard let device = AVCaptureDevice.default(
+ .builtInWideAngleCamera, for: .video, position: position)
+ ?? AVCaptureDevice.default(for: .video) else {
+ throw NSError(domain: "KometVideoNote", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "no camera found"])
+ }
+ let input = try AVCaptureDeviceInput(device: device)
+ guard session.canAddInput(input) else {
+ throw NSError(domain: "KometVideoNote", code: 2,
+ userInfo: [NSLocalizedDescriptionKey: "camera input rejected"])
+ }
+ session.addInput(input)
+ deviceInput = input
+
+ if session.canSetSessionPreset(.hd1280x720) {
+ session.sessionPreset = .hd1280x720
+ }
+
+ if audioInput == nil,
+ let microphone = AVCaptureDevice.default(for: .audio),
+ let input = try? AVCaptureDeviceInput(device: microphone),
+ session.canAddInput(input) {
+ session.addInput(input)
+ audioInput = input
+ }
+
+ if !session.outputs.contains(videoOutput) {
+ videoOutput.videoSettings = [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
+ ]
+ videoOutput.alwaysDiscardsLateVideoFrames = true
+ videoOutput.setSampleBufferDelegate(self, queue: queue)
+ if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) }
+ }
+ if !session.outputs.contains(audioOutput) {
+ audioOutput.setSampleBufferDelegate(self, queue: queue)
+ if session.canAddOutput(audioOutput) { session.addOutput(audioOutput) }
+ }
+
+ if let connection = videoOutput.connection(with: .video) {
+ if connection.isVideoOrientationSupported {
+ connection.videoOrientation = .portrait
+ }
+ if connection.isVideoMirroringSupported {
+ connection.automaticallyAdjustsVideoMirroring = false
+ connection.isVideoMirrored = position == .front
+ }
+ }
+
+ if (try? device.lockForConfiguration()) != nil {
+ let duration = CMTimeMake(value: 1, timescale: Int32(fps))
+ if device.activeFormat.videoSupportedFrameRateRanges.contains(where: {
+ $0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate
+ }) {
+ device.activeVideoMinFrameDuration = duration
+ device.activeVideoMaxFrameDuration = duration
+ }
+ device.unlockForConfiguration()
+ }
+
+ pixelBufferPool = Self.makePool(edge: edge)
+ }
+
+ private func prepareWriter() throws {
+ let directory = FileManager.default.temporaryDirectory
+ let url = directory.appendingPathComponent(
+ "komet_note_\(Int(Date().timeIntervalSince1970 * 1000)).mp4")
+ try? FileManager.default.removeItem(at: url)
+
+ let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
+ let videoSettings: [String: Any] = [
+ AVVideoCodecKey: AVVideoCodecType.h264,
+ AVVideoWidthKey: edge,
+ AVVideoHeightKey: edge,
+ AVVideoCompressionPropertiesKey: [
+ AVVideoAverageBitRateKey: 1_024_000,
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
+ ],
+ ]
+ let video = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
+ video.expectsMediaDataInRealTime = true
+ guard writer.canAdd(video) else {
+ throw NSError(domain: "KometVideoNote", code: 3,
+ userInfo: [NSLocalizedDescriptionKey: "video input rejected"])
+ }
+ writer.add(video)
+
+ let audioSettings: [String: Any] = [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVNumberOfChannelsKey: 1,
+ AVSampleRateKey: 44100,
+ AVEncoderBitRateKey: 64000,
+ ]
+ let audio = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
+ audio.expectsMediaDataInRealTime = true
+ if writer.canAdd(audio) { writer.add(audio) }
+
+ adaptor = AVAssetWriterInputPixelBufferAdaptor(
+ assetWriterInput: video,
+ sourcePixelBufferAttributes: [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey as String: edge,
+ kCVPixelBufferHeightKey as String: edge,
+ ])
+
+ guard writer.startWriting() else {
+ throw NSError(domain: "KometVideoNote", code: 4,
+ userInfo: [NSLocalizedDescriptionKey: "writer refused to start"])
+ }
+
+ self.writer = writer
+ self.writerVideo = video
+ self.writerAudio = writer.inputs.contains(audio) ? audio : nil
+ self.outputURL = url
+ }
+
+ private static func makePool(edge: Int) -> CVPixelBufferPool? {
+ let attributes: [String: Any] = [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey as String: edge,
+ kCVPixelBufferHeightKey as String: edge,
+ kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](),
+ ]
+ var pool: CVPixelBufferPool?
+ CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes as CFDictionary, &pool)
+ return pool
+ }
+
+ private func squareBuffer(from source: CVPixelBuffer) -> CVPixelBuffer? {
+ guard let pool = pixelBufferPool else { return nil }
+ var target: CVPixelBuffer?
+ guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &target)
+ == kCVReturnSuccess, let output = target else { return nil }
+
+ let image = CIImage(cvPixelBuffer: source)
+ let extent = image.extent
+ let side = min(extent.width, extent.height)
+ let cropped = image.cropped(to: CGRect(
+ x: extent.midX - side / 2,
+ y: extent.midY - side / 2,
+ width: side,
+ height: side))
+ let scale = CGFloat(edge) / side
+ let scaled = cropped
+ .transformed(by: CGAffineTransform(translationX: -cropped.extent.origin.x,
+ y: -cropped.extent.origin.y))
+ .transformed(by: CGAffineTransform(scaleX: scale, y: scale))
+
+ ciContext.render(scaled, to: output)
+ return output
+ }
+
+ private static func fail(_ result: @escaping FlutterResult, _ code: String, _ message: String) {
+ DispatchQueue.main.async {
+ result(FlutterError(code: code, message: message, details: nil))
+ }
+ }
+}
+
+extension KometVideoNote: AVCaptureVideoDataOutputSampleBufferDelegate,
+ AVCaptureAudioDataOutputSampleBufferDelegate {
+ func captureOutput(
+ _ output: AVCaptureOutput,
+ didOutput sampleBuffer: CMSampleBuffer,
+ from connection: AVCaptureConnection
+ ) {
+ if output === audioOutput {
+ appendAudio(sampleBuffer)
+ return
+ }
+ guard let source = CMSampleBufferGetImageBuffer(sampleBuffer),
+ let square = squareBuffer(from: source) else { return }
+
+ texture.push(square)
+ let id = textureId
+ if id != 0 {
+ DispatchQueue.main.async { self.registry.textureFrameAvailable(id) }
+ }
+
+ guard recording, let writer = writer, let input = writerVideo,
+ let adaptor = adaptor else { return }
+ let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+ if !sessionStarted {
+ writer.startSession(atSourceTime: timestamp)
+ sessionStarted = true
+ }
+ guard input.isReadyForMoreMediaData else { return }
+ adaptor.append(square, withPresentationTime: timestamp)
+ }
+
+ private func appendAudio(_ sampleBuffer: CMSampleBuffer) {
+ guard recording, sessionStarted, let input = writerAudio,
+ input.isReadyForMoreMediaData else { return }
+ input.append(sampleBuffer)
+ }
+}
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..1eb2e97
--- /dev/null
+++ b/ios/Runner/Runner.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)ru.komet.app
+
+
+
diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart
index 4fd8b34..54dca5f 100644
--- a/lib/core/media/native_video_note_recorder.dart
+++ b/lib/core/media/native_video_note_recorder.dart
@@ -4,17 +4,18 @@ import 'package:flutter/services.dart';
import '../utils/logger.dart';
-/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет
-/// квадрат сразу при съёмке — как официальный клиент (по умолчанию 480×480@30,
-/// размер и fps настраиваются в дев-меню). Превью отдаётся через Flutter
-/// [Texture] по [textureId]. media3-перекод не используется (серверный
+/// Нативная запись видео-кружка: пишет квадрат сразу при съёмке — как
+/// официальный клиент (по умолчанию 480×480@30, размер и fps настраиваются
+/// в дев-меню). На Android — Camera2 + MediaRecorder, на iOS —
+/// AVCaptureSession + AVAssetWriter. Превью отдаётся через Flutter
+/// [Texture] по [textureId]. Перекодирование не используется (серверный
/// валидатор принимает только нативно записанный MP4).
class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId;
bool hasFlash = false;
- bool get isAvailable => Platform.isAndroid;
+ bool get isAvailable => Platform.isAndroid || Platform.isIOS;
Future requestPermission() async {
if (!isAvailable) return false;
diff --git a/lib/core/media/opus_ogg_encoder.dart b/lib/core/media/opus_ogg_encoder.dart
index b49a60a..e16ab41 100644
--- a/lib/core/media/opus_ogg_encoder.dart
+++ b/lib/core/media/opus_ogg_encoder.dart
@@ -26,22 +26,24 @@ class OpusOggEncoder {
/// Лениво загружает libopus и инициализирует opus_dart: на Windows —
/// вендоренную `opus.dll` рядом с exe, на Android — через
- /// `opus_flutter_android`. Возвращает `false`, если кодек недоступен.
+ /// `opus_flutter_android`, на iOS/macOS — статически слинкованную
+ /// `ogg_opus_player` (см. `-force_load` в ios/Podfile).
+ /// Возвращает `false`, если кодек недоступен.
static Future ensureAvailable() async {
if (_initialized) return _available;
_initialized = true;
try {
- // libopus.so на Android бандлится плагином opus_flutter_android,
- // opus.dll — вендоренная рядом с exe на Windows.
- final String libName;
- if (Platform.isWindows) {
- libName = 'opus.dll';
+ final DynamicLibrary lib;
+ if (Platform.isIOS || Platform.isMacOS) {
+ lib = DynamicLibrary.process();
+ } else if (Platform.isWindows) {
+ lib = DynamicLibrary.open('opus.dll');
} else if (Platform.isAndroid) {
- libName = 'libopus.so';
+ lib = DynamicLibrary.open('libopus.so');
} else {
return false;
}
- initOpus(DynamicLibrary.open(libName) as dynamic);
+ initOpus(lib as dynamic);
_available = true;
} catch (e) {
logger.w('OpusOggEncoder: libopus недоступна: $e');
diff --git a/lib/core/media/video_note_cropper.dart b/lib/core/media/video_note_cropper.dart
index 257ef0d..c469b13 100644
--- a/lib/core/media/video_note_cropper.dart
+++ b/lib/core/media/video_note_cropper.dart
@@ -5,14 +5,14 @@ import 'package:flutter/services.dart';
import '../utils/logger.dart';
/// Центр-кроп записанного видео в квадрат для видеосообщений-кружков.
-/// На Android выполняется нативно (media3 Transformer, без искажений —
-/// заполняет квадрат и обрезает лишнее по бокам). На других платформах
-/// возвращает `null` (кружки там не записываются).
+/// Выполняется нативно: на Android — media3 Transformer, на iOS —
+/// AVAssetExportSession. Без искажений: заполняет квадрат и обрезает
+/// лишнее по бокам. На других платформах возвращает `null`.
class VideoNoteCropper {
static const _channel = MethodChannel('ru.komet.app/video');
static Future cropSquare(String input, {int size = 480}) async {
- if (!Platform.isAndroid) return null;
+ if (!Platform.isAndroid && !Platform.isIOS) return null;
try {
final dot = input.lastIndexOf('.');
final base = dot > 0 ? input.substring(0, dot) : input;
diff --git a/lib/core/media/video_transcoder.dart b/lib/core/media/video_transcoder.dart
index c249569..77d7208 100644
--- a/lib/core/media/video_transcoder.dart
+++ b/lib/core/media/video_transcoder.dart
@@ -68,23 +68,25 @@ class VideoExportSpec {
class VideoTranscoder {
static const _channel = MethodChannel('ru.komet.app/video');
+ static bool get _native => Platform.isAndroid || Platform.isIOS;
+
static Process? _desktopProcess;
static bool _desktopCancelled = false;
static bool get supported =>
- Platform.isAndroid || (DesktopVideoProbe.supported && _ffmpegReady);
+ _native || (DesktopVideoProbe.supported && _ffmpegReady);
static bool _ffmpegReady = false;
static Future ensureAvailable() async {
- if (Platform.isAndroid) return true;
+ if (_native) return true;
if (!DesktopVideoProbe.supported) return false;
_ffmpegReady = await DesktopVideoProbe.toolsAvailable();
return _ffmpegReady;
}
static Future probe(String path) async {
- if (Platform.isAndroid) {
+ if (_native) {
try {
final res = await _channel.invokeMapMethod('probe', {
'input': path,
@@ -113,7 +115,7 @@ class VideoTranscoder {
bool precise = false,
}) async {
if (timesMs.isEmpty) return const [];
- if (Platform.isAndroid) {
+ if (_native) {
try {
final res = await _channel.invokeListMethod