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('frames', { 'input': path, @@ -150,13 +152,13 @@ class VideoTranscoder { VideoExportSpec spec, { void Function(double progress)? onProgress, }) async { - if (Platform.isAndroid) return _exportAndroid(spec, onProgress); + if (_native) return _exportNative(spec, onProgress); if (!await ensureAvailable()) return false; return _exportFfmpeg(spec, onProgress); } static Future cancel() async { - if (Platform.isAndroid) { + if (_native) { try { await _channel.invokeMethod('editCancel'); } catch (_) {} @@ -166,7 +168,7 @@ class VideoTranscoder { _desktopProcess?.kill(); } - static Future _exportAndroid( + static Future _exportNative( VideoExportSpec spec, void Function(double)? onProgress, ) async { @@ -179,7 +181,7 @@ class VideoTranscoder { } catch (_) {} }); try { - final ok = await _channel.invokeMethod('edit', _androidArgs(spec)); + final ok = await _channel.invokeMethod('edit', _nativeArgs(spec)); return ok == true; } catch (e) { logger.w('VideoTranscoder.export: $e'); @@ -189,7 +191,7 @@ class VideoTranscoder { } } - static Map _androidArgs(VideoExportSpec spec) { + static Map _nativeArgs(VideoExportSpec spec) { final crop = spec.crop; return { 'input': spec.input, diff --git a/lib/core/push/notification_bridge.dart b/lib/core/push/notification_bridge.dart index 0f506d4..e613482 100644 --- a/lib/core/push/notification_bridge.dart +++ b/lib/core/push/notification_bridge.dart @@ -24,16 +24,16 @@ class NotificationBridge { int _retriesLeft = 0; Timer? _retry; - bool get _android { + bool get _native { try { - return Platform.isAndroid; + return Platform.isAndroid || Platform.isIOS; } catch (_) { return false; } } void init() { - if (_started || !_android) return; + if (_started || !_native) return; _started = true; _events.receiveBroadcastStream().listen( _onEvent, @@ -50,7 +50,7 @@ class NotificationBridge { } Future checkInitialChat() async { - if (!_android) return; + if (!_native) return; try { _onEvent(await _method.invokeMethod('consumeInitialChat')); } catch (e) { @@ -59,7 +59,7 @@ class NotificationBridge { } Future setActiveChat(int chatId) async { - if (!_android || chatId <= 0) return; + if (!_native || chatId <= 0) return; if (_activeChatId == chatId) return; _activeChatId = chatId; try { @@ -70,7 +70,7 @@ class NotificationBridge { } Future clearActiveChat(int chatId) async { - if (!_android) return; + if (!_native) return; if (chatId > 0 && _activeChatId != chatId) return; _activeChatId = 0; try { diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 2c92299..d817ed3 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -7,6 +7,10 @@ class TokenStorage { static const _secure = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + synchronizable: false, + ), mOptions: MacOsOptions(usesDataProtectionKeychain: false), ); diff --git a/lib/core/utils/link_opener.dart b/lib/core/utils/link_opener.dart index 089fa3f..5937900 100644 --- a/lib/core/utils/link_opener.dart +++ b/lib/core/utils/link_opener.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/widgets.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -26,10 +28,13 @@ Future openLocationOnMap( double? zoom, }) async { final z = (zoom ?? 15).round(); - final geo = Uri.parse('geo:$latitude,$longitude?z=$z'); - if (await canLaunchUrl(geo)) { - final ok = await launchUrl(geo, mode: LaunchMode.externalApplication); - if (ok) return; + for (final uri in _nativeMapUris(latitude, longitude, z)) { + try { + if (!await canLaunchUrl(uri)) continue; + if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return; + } catch (_) { + continue; + } } if (!context.mounted) return; await openExternalUrl( @@ -37,3 +42,16 @@ Future openLocationOnMap( 'https://yandex.ru/maps/?pt=$longitude,$latitude&z=$z&l=map', ); } + +List _nativeMapUris(double latitude, double longitude, int zoom) { + if (Platform.isIOS) { + return [ + Uri.parse( + 'yandexmaps://maps.yandex.ru/' + '?ll=$longitude,$latitude&z=$zoom&pt=$longitude,$latitude', + ), + Uri.parse('maps://?ll=$latitude,$longitude&q=$latitude,$longitude'), + ]; + } + return [Uri.parse('geo:$latitude,$longitude?z=$zoom')]; +} diff --git a/lib/core/utils/share_origin.dart b/lib/core/utils/share_origin.dart new file mode 100644 index 0000000..bcf7f96 --- /dev/null +++ b/lib/core/utils/share_origin.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +Rect shareOriginOf(BuildContext? context) { + final box = context?.findRenderObject() as RenderBox?; + if (box != null && box.hasSize && box.attached) { + final rect = box.localToGlobal(Offset.zero) & box.size; + if (!rect.isEmpty) return rect; + } + final view = WidgetsBinding.instance.platformDispatcher.views.first; + final size = view.physicalSize / view.devicePixelRatio; + return Rect.fromCenter( + center: Offset(size.width / 2, size.height / 2), + width: 1, + height: 1, + ); +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 008d058..60875a0 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -529,10 +529,7 @@ class _LoginScreenState extends State { void _validateAndSubmit() { if (!_isTOSRead) { - showCustomNotification( - context, - AppLocalizations.of(context)!.loginReadTermsNotification, - ); + _showTOS(context); return; } _showPhoneConfirmationDialog(_phoneController.text); diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index 07d6bd3..c15fb50 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -85,12 +87,15 @@ class _NotificationsScreenState extends State void _onFkmTap() { final l10n = AppLocalizations.of(context)!; - showCustomNotification( - context, - isOnemeFlavor - ? l10n.notificationsFkmAlreadyHasFcm - : l10n.notificationsFkmDownloadFcm, - ); + final String message; + if (Platform.isIOS) { + message = l10n.notificationsFkmIosUnsupported; + } else if (isOnemeFlavor) { + message = l10n.notificationsFkmAlreadyHasFcm; + } else { + message = l10n.notificationsFkmDownloadFcm; + } + showCustomNotification(context, message); } @override diff --git a/lib/frontend/screens/webapp/web_app_bridge.dart b/lib/frontend/screens/webapp/web_app_bridge.dart index d7078aa..9a03c48 100644 --- a/lib/frontend/screens/webapp/web_app_bridge.dart +++ b/lib/frontend/screens/webapp/web_app_bridge.dart @@ -10,6 +10,7 @@ import '../../../core/storage/webapp_storage.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/utils/link_opener.dart'; import '../../../core/utils/media_saver.dart'; +import '../../../core/utils/share_origin.dart'; import '../../../main.dart' show api, messagesModule, webAppModule; import '../../widgets/confirm_dialog.dart'; import '../chats/chat_list_screen.dart' show openForwardScreen; @@ -433,7 +434,10 @@ class WebAppBridge { return; } try { - final result = await Share.share(text); + final result = await Share.share( + text, + sharePositionOrigin: shareOriginOf(contextResolver()), + ); _send(method, { 'requestId': ?requestId, 'status': result.status == ShareResultStatus.dismissed diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index 37ec2e7..93453f1 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -264,6 +264,9 @@ class _WebAppScreenState extends State { supportZoom: false, transparentBackground: true, mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + sharedCookiesEnabled: true, + allowsBackForwardNavigationGestures: true, useHybridComposition: true, supportMultipleWindows: true, allowFileAccess: false, diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 20ce065..83d4def 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -8,6 +8,7 @@ import '../../backend/modules/links.dart'; import '../../core/cache/info_cache.dart'; import '../../core/links/max_link.dart'; import '../../core/storage/app_database.dart'; +import '../../core/utils/share_origin.dart'; import '../../main.dart'; import '../screens/chats/chat_screen.dart'; import '../screens/contacts/open_contact_profile.dart'; @@ -125,7 +126,7 @@ Future _shareOwnLink(BuildContext context) async { return true; } try { - await Share.share(link); + await Share.share(link, sharePositionOrigin: shareOriginOf(context)); } catch (_) { if (context.mounted) { showCustomNotification(context, 'Не удалось поделиться ссылкой'); diff --git a/lib/frontend/widgets/text_entity_actions.dart b/lib/frontend/widgets/text_entity_actions.dart index 879a044..6e903ed 100644 --- a/lib/frontend/widgets/text_entity_actions.dart +++ b/lib/frontend/widgets/text_entity_actions.dart @@ -45,7 +45,8 @@ void showPhoneEntityMenu( label: 'Скопировать номер телефона', onTap: () => copyTextEntity(context, phone, 'Номер скопирован'), ), - if (defaultTargetPlatform == TargetPlatform.android) + if (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS) ChatMenuItem( icon: Symbols.call, label: 'Позвонить', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a8fff5e..765d113 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,7 +11,6 @@ "loginConfirmPhoneTitle": "Is this the correct number?", "loginEdit": "Change", "loginDone": "Done", - "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoofing", "loginProxy": "Proxy", "loginChangeServer": "Change server", @@ -259,6 +258,7 @@ }, "notificationsFkmAlreadyHasFcm": "Why? You already have FCM.", "notificationsFkmDownloadFcm": "Better download the FCM version.", + "notificationsFkmIosUnsupported": "Push notifications are not available on iOS yet.", "notificationsTitle": "Notifications", "notificationsFkmSectionTitle": "FKM", "notificationsFkmEnableLabel": "Enable notifications", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e2932f9..77a6c39 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -164,12 +164,6 @@ abstract class AppLocalizations { /// **'Done'** String get loginDone; - /// No description provided for @loginReadTermsNotification. - /// - /// In en, this message translates to: - /// **'Please read the terms of use first'** - String get loginReadTermsNotification; - /// No description provided for @loginSpoofRedacted. /// /// In en, this message translates to: @@ -1424,6 +1418,12 @@ abstract class AppLocalizations { /// **'Better download the FCM version.'** String get notificationsFkmDownloadFcm; + /// No description provided for @notificationsFkmIosUnsupported. + /// + /// In en, this message translates to: + /// **'Push notifications are not available on iOS yet.'** + String get notificationsFkmIosUnsupported; + /// No description provided for @notificationsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b3fc125..80849ea 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -42,9 +42,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginDone => 'Done'; - @override - String get loginReadTermsNotification => 'Please read the terms of use first'; - @override String get loginSpoofRedacted => 'Spoofing'; @@ -702,6 +699,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationsFkmDownloadFcm => 'Better download the FCM version.'; + @override + String get notificationsFkmIosUnsupported => + 'Push notifications are not available on iOS yet.'; + @override String get notificationsTitle => 'Notifications'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5c405f4..b484fcc 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -42,10 +42,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginDone => 'Готово'; - @override - String get loginReadTermsNotification => - 'Сначала прочитайте условия использования'; - @override String get loginSpoofRedacted => 'Подмена данных'; @@ -707,6 +703,10 @@ class AppLocalizationsRu extends AppLocalizations { String get notificationsFkmDownloadFcm => 'Установите FCM версию с официального источника'; + @override + String get notificationsFkmIosUnsupported => + 'На iOS пуш-уведомления пока недоступны'; + @override String get notificationsTitle => 'Уведомления'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2f7dff1..82651fa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -11,7 +11,6 @@ "loginConfirmPhoneTitle": "Это правильный номер?", "loginEdit": "Изменить", "loginDone": "Готово", - "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подмена данных", "loginProxy": "Прокси", "loginChangeServer": "Смена сервера", @@ -238,6 +237,7 @@ "notificationsSaveFailed": "Не удалось сохранить: {error}", "notificationsFkmAlreadyHasFcm": "А зачем? У тебя уже FCM.", "notificationsFkmDownloadFcm": "Установите FCM версию с официального источника", + "notificationsFkmIosUnsupported": "На iOS пуш-уведомления пока недоступны", "notificationsTitle": "Уведомления", "notificationsFkmSectionTitle": "FKM", "notificationsFkmEnableLabel": "Включить уведомления",