fix/refactor: IOS тоже члены общества!!! Не полноценные пока что но все-же. Теперь при нажатии по кнопке входа bottom sheet с условиями открывается сам.

This commit is contained in:
Jganenokk
2026-08-20 17:21:59 +07:00
parent 3e4edc535c
commit 0030ded1f4
32 changed files with 1242 additions and 99 deletions
+3
View File
@@ -79,6 +79,9 @@ jobs:
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>get-task-allow</key><true/> <key>get-task-allow</key><true/>
<key>application-identifier</key><string>ru.komet.app</string>
<key>keychain-access-groups</key>
<array><string>ru.komet.app</string></array>
</dict> </dict>
</plist> </plist>
PLIST PLIST
+29
View File
@@ -71,3 +71,32 @@ jobs:
- name: Build Android APK - name: Build Android APK
run: flutter build apk --release --flavor komet 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
+3
View File
@@ -246,6 +246,9 @@ jobs:
<dict> <dict>
<key>platform-application</key><true/> <key>platform-application</key><true/>
<key>get-task-allow</key><true/> <key>get-task-allow</key><true/>
<key>application-identifier</key><string>ru.komet.app</string>
<key>keychain-access-groups</key>
<array><string>ru.komet.app</string></array>
<key>com.apple.private.security.no-container</key><true/> <key>com.apple.private.security.no-container</key><true/>
</dict> </dict>
</plist> </plist>
+3
View File
@@ -233,6 +233,9 @@ jobs:
<dict> <dict>
<key>platform-application</key><true/> <key>platform-application</key><true/>
<key>get-task-allow</key><true/> <key>get-task-allow</key><true/>
<key>application-identifier</key><string>ru.komet.app</string>
<key>keychain-access-groups</key>
<array><string>ru.komet.app</string></array>
<key>com.apple.private.security.no-container</key><true/> <key>com.apple.private.security.no-container</key><true/>
</dict> </dict>
</plist> </plist>
@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ShortcutInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Build import android.os.Build
@@ -275,7 +276,7 @@ class KometNotifier(private val ctx: Context) {
.setIntent(intent) .setIntent(intent)
.setPerson(person) .setPerson(person)
.setIcon(person.icon) .setIcon(person.icon)
.setCategories(setOf(ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION)) .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setLocusId(LocusIdCompat(id)) .setLocusId(LocusIdCompat(id))
.build() .build()
ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut) ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut)
+60
View File
@@ -69,8 +69,68 @@ target 'Runner' do
end end
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| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(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
end end
+17
View File
@@ -12,6 +12,9 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; }; 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 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 */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 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 = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE550001 /* KometVideo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideo.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideoNote.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE550003 /* KometNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometNotifications.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE550004 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -157,6 +164,10 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
AA11BB22CC33DD44EE550001 /* KometVideo.swift */,
AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */,
AA11BB22CC33DD44EE550003 /* KometNotifications.swift */,
AA11BB22CC33DD44EE550004 /* Runner.entitlements */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -396,6 +407,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 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 */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -495,6 +509,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -677,6 +692,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -699,6 +715,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+136 -34
View File
@@ -1,50 +1,152 @@
import Flutter import Flutter
import UIKit 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 @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
private var channels: [FlutterMethodChannel] = []
private var eventChannels: [FlutterEventChannel] = []
private var streamHandlers: [KometStreamHandler] = []
private var videoNote: KometVideoNote?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
KometNotifications.shared.start()
let controller = window?.rootViewController as? FlutterViewController if let controller = window?.rootViewController as? FlutterViewController {
if let messenger = controller?.binaryMessenger { let messenger = controller.binaryMessenger
let channel = FlutterMethodChannel( registerAppIcon(messenger)
name: "ru.komet.app/app_icon", registerVideo(messenger)
binaryMessenger: messenger registerVideoNote(messenger)
) registerNotifications(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)
}
}
}
} }
return super.application(application, didFinishLaunchingWithOptions: launchOptions) 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)
}
}
} }
+26
View File
@@ -84,5 +84,31 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleLocalizations</key>
<array>
<string>ru</string>
<string>en</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tel</string>
<string>telprompt</string>
<string>sms</string>
<string>mailto</string>
<string>maps</string>
<string>comgooglemaps</string>
<string>yandexmaps</string>
<string>yandexnavi</string>
</array>
</dict> </dict>
</plist> </plist>
+105
View File
@@ -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()
}
}
+333
View File
@@ -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)
}
}
+396
View File
@@ -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<CVPixelBuffer>? {
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)
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)ru.komet.app</string>
</array>
</dict>
</plist>
@@ -4,17 +4,18 @@ import 'package:flutter/services.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет /// Нативная запись видео-кружка: пишет квадрат сразу при съёмке — как
/// квадрат сразу при съёмке — как официальный клиент (по умолчанию 480×480@30, /// официальный клиент (по умолчанию 480×480@30, размер и fps настраиваются
/// размер и fps настраиваются в дев-меню). Превью отдаётся через Flutter /// в дев-меню). На Android — Camera2 + MediaRecorder, на iOS —
/// [Texture] по [textureId]. media3-перекод не используется (серверный /// AVCaptureSession + AVAssetWriter. Превью отдаётся через Flutter
/// [Texture] по [textureId]. Перекодирование не используется (серверный
/// валидатор принимает только нативно записанный MP4). /// валидатор принимает только нативно записанный MP4).
class NativeVideoNoteRecorder { class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note'); static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId; int? textureId;
bool hasFlash = false; bool hasFlash = false;
bool get isAvailable => Platform.isAndroid; bool get isAvailable => Platform.isAndroid || Platform.isIOS;
Future<bool> requestPermission() async { Future<bool> requestPermission() async {
if (!isAvailable) return false; if (!isAvailable) return false;
+10 -8
View File
@@ -26,22 +26,24 @@ class OpusOggEncoder {
/// Лениво загружает libopus и инициализирует opus_dart: на Windows — /// Лениво загружает libopus и инициализирует opus_dart: на Windows —
/// вендоренную `opus.dll` рядом с exe, на Android — через /// вендоренную `opus.dll` рядом с exe, на Android — через
/// `opus_flutter_android`. Возвращает `false`, если кодек недоступен. /// `opus_flutter_android`, на iOS/macOS — статически слинкованную
/// `ogg_opus_player` (см. `-force_load` в ios/Podfile).
/// Возвращает `false`, если кодек недоступен.
static Future<bool> ensureAvailable() async { static Future<bool> ensureAvailable() async {
if (_initialized) return _available; if (_initialized) return _available;
_initialized = true; _initialized = true;
try { try {
// libopus.so на Android бандлится плагином opus_flutter_android, final DynamicLibrary lib;
// opus.dll — вендоренная рядом с exe на Windows. if (Platform.isIOS || Platform.isMacOS) {
final String libName; lib = DynamicLibrary.process();
if (Platform.isWindows) { } else if (Platform.isWindows) {
libName = 'opus.dll'; lib = DynamicLibrary.open('opus.dll');
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
libName = 'libopus.so'; lib = DynamicLibrary.open('libopus.so');
} else { } else {
return false; return false;
} }
initOpus(DynamicLibrary.open(libName) as dynamic); initOpus(lib as dynamic);
_available = true; _available = true;
} catch (e) { } catch (e) {
logger.w('OpusOggEncoder: libopus недоступна: $e'); logger.w('OpusOggEncoder: libopus недоступна: $e');
+4 -4
View File
@@ -5,14 +5,14 @@ import 'package:flutter/services.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
/// Центр-кроп записанного видео в квадрат для видеосообщений-кружков. /// Центр-кроп записанного видео в квадрат для видеосообщений-кружков.
/// На Android выполняется нативно (media3 Transformer, без искажений /// Выполняется нативно: на Android — media3 Transformer, на iOS
/// заполняет квадрат и обрезает лишнее по бокам). На других платформах /// AVAssetExportSession. Без искажений: заполняет квадрат и обрезает
/// возвращает `null` (кружки там не записываются). /// лишнее по бокам. На других платформах возвращает `null`.
class VideoNoteCropper { class VideoNoteCropper {
static const _channel = MethodChannel('ru.komet.app/video'); static const _channel = MethodChannel('ru.komet.app/video');
static Future<String?> cropSquare(String input, {int size = 480}) async { static Future<String?> cropSquare(String input, {int size = 480}) async {
if (!Platform.isAndroid) return null; if (!Platform.isAndroid && !Platform.isIOS) return null;
try { try {
final dot = input.lastIndexOf('.'); final dot = input.lastIndexOf('.');
final base = dot > 0 ? input.substring(0, dot) : input; final base = dot > 0 ? input.substring(0, dot) : input;
+11 -9
View File
@@ -68,23 +68,25 @@ class VideoExportSpec {
class VideoTranscoder { class VideoTranscoder {
static const _channel = MethodChannel('ru.komet.app/video'); static const _channel = MethodChannel('ru.komet.app/video');
static bool get _native => Platform.isAndroid || Platform.isIOS;
static Process? _desktopProcess; static Process? _desktopProcess;
static bool _desktopCancelled = false; static bool _desktopCancelled = false;
static bool get supported => static bool get supported =>
Platform.isAndroid || (DesktopVideoProbe.supported && _ffmpegReady); _native || (DesktopVideoProbe.supported && _ffmpegReady);
static bool _ffmpegReady = false; static bool _ffmpegReady = false;
static Future<bool> ensureAvailable() async { static Future<bool> ensureAvailable() async {
if (Platform.isAndroid) return true; if (_native) return true;
if (!DesktopVideoProbe.supported) return false; if (!DesktopVideoProbe.supported) return false;
_ffmpegReady = await DesktopVideoProbe.toolsAvailable(); _ffmpegReady = await DesktopVideoProbe.toolsAvailable();
return _ffmpegReady; return _ffmpegReady;
} }
static Future<VideoInfo?> probe(String path) async { static Future<VideoInfo?> probe(String path) async {
if (Platform.isAndroid) { if (_native) {
try { try {
final res = await _channel.invokeMapMethod<String, dynamic>('probe', { final res = await _channel.invokeMapMethod<String, dynamic>('probe', {
'input': path, 'input': path,
@@ -113,7 +115,7 @@ class VideoTranscoder {
bool precise = false, bool precise = false,
}) async { }) async {
if (timesMs.isEmpty) return const []; if (timesMs.isEmpty) return const [];
if (Platform.isAndroid) { if (_native) {
try { try {
final res = await _channel.invokeListMethod<Object?>('frames', { final res = await _channel.invokeListMethod<Object?>('frames', {
'input': path, 'input': path,
@@ -150,13 +152,13 @@ class VideoTranscoder {
VideoExportSpec spec, { VideoExportSpec spec, {
void Function(double progress)? onProgress, void Function(double progress)? onProgress,
}) async { }) async {
if (Platform.isAndroid) return _exportAndroid(spec, onProgress); if (_native) return _exportNative(spec, onProgress);
if (!await ensureAvailable()) return false; if (!await ensureAvailable()) return false;
return _exportFfmpeg(spec, onProgress); return _exportFfmpeg(spec, onProgress);
} }
static Future<void> cancel() async { static Future<void> cancel() async {
if (Platform.isAndroid) { if (_native) {
try { try {
await _channel.invokeMethod<void>('editCancel'); await _channel.invokeMethod<void>('editCancel');
} catch (_) {} } catch (_) {}
@@ -166,7 +168,7 @@ class VideoTranscoder {
_desktopProcess?.kill(); _desktopProcess?.kill();
} }
static Future<bool> _exportAndroid( static Future<bool> _exportNative(
VideoExportSpec spec, VideoExportSpec spec,
void Function(double)? onProgress, void Function(double)? onProgress,
) async { ) async {
@@ -179,7 +181,7 @@ class VideoTranscoder {
} catch (_) {} } catch (_) {}
}); });
try { try {
final ok = await _channel.invokeMethod<bool>('edit', _androidArgs(spec)); final ok = await _channel.invokeMethod<bool>('edit', _nativeArgs(spec));
return ok == true; return ok == true;
} catch (e) { } catch (e) {
logger.w('VideoTranscoder.export: $e'); logger.w('VideoTranscoder.export: $e');
@@ -189,7 +191,7 @@ class VideoTranscoder {
} }
} }
static Map<String, dynamic> _androidArgs(VideoExportSpec spec) { static Map<String, dynamic> _nativeArgs(VideoExportSpec spec) {
final crop = spec.crop; final crop = spec.crop;
return { return {
'input': spec.input, 'input': spec.input,
+6 -6
View File
@@ -24,16 +24,16 @@ class NotificationBridge {
int _retriesLeft = 0; int _retriesLeft = 0;
Timer? _retry; Timer? _retry;
bool get _android { bool get _native {
try { try {
return Platform.isAndroid; return Platform.isAndroid || Platform.isIOS;
} catch (_) { } catch (_) {
return false; return false;
} }
} }
void init() { void init() {
if (_started || !_android) return; if (_started || !_native) return;
_started = true; _started = true;
_events.receiveBroadcastStream().listen( _events.receiveBroadcastStream().listen(
_onEvent, _onEvent,
@@ -50,7 +50,7 @@ class NotificationBridge {
} }
Future<void> checkInitialChat() async { Future<void> checkInitialChat() async {
if (!_android) return; if (!_native) return;
try { try {
_onEvent(await _method.invokeMethod<dynamic>('consumeInitialChat')); _onEvent(await _method.invokeMethod<dynamic>('consumeInitialChat'));
} catch (e) { } catch (e) {
@@ -59,7 +59,7 @@ class NotificationBridge {
} }
Future<void> setActiveChat(int chatId) async { Future<void> setActiveChat(int chatId) async {
if (!_android || chatId <= 0) return; if (!_native || chatId <= 0) return;
if (_activeChatId == chatId) return; if (_activeChatId == chatId) return;
_activeChatId = chatId; _activeChatId = chatId;
try { try {
@@ -70,7 +70,7 @@ class NotificationBridge {
} }
Future<void> clearActiveChat(int chatId) async { Future<void> clearActiveChat(int chatId) async {
if (!_android) return; if (!_native) return;
if (chatId > 0 && _activeChatId != chatId) return; if (chatId > 0 && _activeChatId != chatId) return;
_activeChatId = 0; _activeChatId = 0;
try { try {
+4
View File
@@ -7,6 +7,10 @@ class TokenStorage {
static const _secure = FlutterSecureStorage( static const _secure = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
synchronizable: false,
),
mOptions: MacOsOptions(usesDataProtectionKeychain: false), mOptions: MacOsOptions(usesDataProtectionKeychain: false),
); );
+22 -4
View File
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -26,10 +28,13 @@ Future<void> openLocationOnMap(
double? zoom, double? zoom,
}) async { }) async {
final z = (zoom ?? 15).round(); final z = (zoom ?? 15).round();
final geo = Uri.parse('geo:$latitude,$longitude?z=$z'); for (final uri in _nativeMapUris(latitude, longitude, z)) {
if (await canLaunchUrl(geo)) { try {
final ok = await launchUrl(geo, mode: LaunchMode.externalApplication); if (!await canLaunchUrl(uri)) continue;
if (ok) return; if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return;
} catch (_) {
continue;
}
} }
if (!context.mounted) return; if (!context.mounted) return;
await openExternalUrl( await openExternalUrl(
@@ -37,3 +42,16 @@ Future<void> openLocationOnMap(
'https://yandex.ru/maps/?pt=$longitude,$latitude&z=$z&l=map', 'https://yandex.ru/maps/?pt=$longitude,$latitude&z=$z&l=map',
); );
} }
List<Uri> _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')];
}
+16
View File
@@ -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,
);
}
+1 -4
View File
@@ -529,10 +529,7 @@ class _LoginScreenState extends State<LoginScreen> {
void _validateAndSubmit() { void _validateAndSubmit() {
if (!_isTOSRead) { if (!_isTOSRead) {
showCustomNotification( _showTOS(context);
context,
AppLocalizations.of(context)!.loginReadTermsNotification,
);
return; return;
} }
_showPhoneConfirmationDialog(_phoneController.text); _showPhoneConfirmationDialog(_phoneController.text);
@@ -1,3 +1,5 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -85,12 +87,15 @@ class _NotificationsScreenState extends State<NotificationsScreen>
void _onFkmTap() { void _onFkmTap() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
showCustomNotification( final String message;
context, if (Platform.isIOS) {
isOnemeFlavor message = l10n.notificationsFkmIosUnsupported;
? l10n.notificationsFkmAlreadyHasFcm } else if (isOnemeFlavor) {
: l10n.notificationsFkmDownloadFcm, message = l10n.notificationsFkmAlreadyHasFcm;
); } else {
message = l10n.notificationsFkmDownloadFcm;
}
showCustomNotification(context, message);
} }
@override @override
@@ -10,6 +10,7 @@ import '../../../core/storage/webapp_storage.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/utils/link_opener.dart'; import '../../../core/utils/link_opener.dart';
import '../../../core/utils/media_saver.dart'; import '../../../core/utils/media_saver.dart';
import '../../../core/utils/share_origin.dart';
import '../../../main.dart' show api, messagesModule, webAppModule; import '../../../main.dart' show api, messagesModule, webAppModule;
import '../../widgets/confirm_dialog.dart'; import '../../widgets/confirm_dialog.dart';
import '../chats/chat_list_screen.dart' show openForwardScreen; import '../chats/chat_list_screen.dart' show openForwardScreen;
@@ -433,7 +434,10 @@ class WebAppBridge {
return; return;
} }
try { try {
final result = await Share.share(text); final result = await Share.share(
text,
sharePositionOrigin: shareOriginOf(contextResolver()),
);
_send(method, { _send(method, {
'requestId': ?requestId, 'requestId': ?requestId,
'status': result.status == ShareResultStatus.dismissed 'status': result.status == ShareResultStatus.dismissed
@@ -264,6 +264,9 @@ class _WebAppScreenState extends State<WebAppScreen> {
supportZoom: false, supportZoom: false,
transparentBackground: true, transparentBackground: true,
mediaPlaybackRequiresUserGesture: false, mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
sharedCookiesEnabled: true,
allowsBackForwardNavigationGestures: true,
useHybridComposition: true, useHybridComposition: true,
supportMultipleWindows: true, supportMultipleWindows: true,
allowFileAccess: false, allowFileAccess: false,
+2 -1
View File
@@ -8,6 +8,7 @@ import '../../backend/modules/links.dart';
import '../../core/cache/info_cache.dart'; import '../../core/cache/info_cache.dart';
import '../../core/links/max_link.dart'; import '../../core/links/max_link.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
import '../../core/utils/share_origin.dart';
import '../../main.dart'; import '../../main.dart';
import '../screens/chats/chat_screen.dart'; import '../screens/chats/chat_screen.dart';
import '../screens/contacts/open_contact_profile.dart'; import '../screens/contacts/open_contact_profile.dart';
@@ -125,7 +126,7 @@ Future<bool> _shareOwnLink(BuildContext context) async {
return true; return true;
} }
try { try {
await Share.share(link); await Share.share(link, sharePositionOrigin: shareOriginOf(context));
} catch (_) { } catch (_) {
if (context.mounted) { if (context.mounted) {
showCustomNotification(context, 'Не удалось поделиться ссылкой'); showCustomNotification(context, 'Не удалось поделиться ссылкой');
@@ -45,7 +45,8 @@ void showPhoneEntityMenu(
label: 'Скопировать номер телефона', label: 'Скопировать номер телефона',
onTap: () => copyTextEntity(context, phone, 'Номер скопирован'), onTap: () => copyTextEntity(context, phone, 'Номер скопирован'),
), ),
if (defaultTargetPlatform == TargetPlatform.android) if (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS)
ChatMenuItem( ChatMenuItem(
icon: Symbols.call, icon: Symbols.call,
label: 'Позвонить', label: 'Позвонить',
+1 -1
View File
@@ -11,7 +11,6 @@
"loginConfirmPhoneTitle": "Is this the correct number?", "loginConfirmPhoneTitle": "Is this the correct number?",
"loginEdit": "Change", "loginEdit": "Change",
"loginDone": "Done", "loginDone": "Done",
"loginReadTermsNotification": "Please read the terms of use first",
"loginSpoofRedacted": "Spoofing", "loginSpoofRedacted": "Spoofing",
"loginProxy": "Proxy", "loginProxy": "Proxy",
"loginChangeServer": "Change server", "loginChangeServer": "Change server",
@@ -259,6 +258,7 @@
}, },
"notificationsFkmAlreadyHasFcm": "Why? You already have FCM.", "notificationsFkmAlreadyHasFcm": "Why? You already have FCM.",
"notificationsFkmDownloadFcm": "Better download the FCM version.", "notificationsFkmDownloadFcm": "Better download the FCM version.",
"notificationsFkmIosUnsupported": "Push notifications are not available on iOS yet.",
"notificationsTitle": "Notifications", "notificationsTitle": "Notifications",
"notificationsFkmSectionTitle": "FKM", "notificationsFkmSectionTitle": "FKM",
"notificationsFkmEnableLabel": "Enable notifications", "notificationsFkmEnableLabel": "Enable notifications",
+6 -6
View File
@@ -164,12 +164,6 @@ abstract class AppLocalizations {
/// **'Done'** /// **'Done'**
String get loginDone; 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. /// No description provided for @loginSpoofRedacted.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1424,6 +1418,12 @@ abstract class AppLocalizations {
/// **'Better download the FCM version.'** /// **'Better download the FCM version.'**
String get notificationsFkmDownloadFcm; 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. /// No description provided for @notificationsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+4 -3
View File
@@ -42,9 +42,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get loginDone => 'Done'; String get loginDone => 'Done';
@override
String get loginReadTermsNotification => 'Please read the terms of use first';
@override @override
String get loginSpoofRedacted => 'Spoofing'; String get loginSpoofRedacted => 'Spoofing';
@@ -702,6 +699,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get notificationsFkmDownloadFcm => 'Better download the FCM version.'; String get notificationsFkmDownloadFcm => 'Better download the FCM version.';
@override
String get notificationsFkmIosUnsupported =>
'Push notifications are not available on iOS yet.';
@override @override
String get notificationsTitle => 'Notifications'; String get notificationsTitle => 'Notifications';
+4 -4
View File
@@ -42,10 +42,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get loginDone => 'Готово'; String get loginDone => 'Готово';
@override
String get loginReadTermsNotification =>
'Сначала прочитайте условия использования';
@override @override
String get loginSpoofRedacted => 'Подмена данных'; String get loginSpoofRedacted => 'Подмена данных';
@@ -707,6 +703,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get notificationsFkmDownloadFcm => String get notificationsFkmDownloadFcm =>
'Установите FCM версию с официального источника'; 'Установите FCM версию с официального источника';
@override
String get notificationsFkmIosUnsupported =>
'На iOS пуш-уведомления пока недоступны';
@override @override
String get notificationsTitle => 'Уведомления'; String get notificationsTitle => 'Уведомления';
+1 -1
View File
@@ -11,7 +11,6 @@
"loginConfirmPhoneTitle": "Это правильный номер?", "loginConfirmPhoneTitle": "Это правильный номер?",
"loginEdit": "Изменить", "loginEdit": "Изменить",
"loginDone": "Готово", "loginDone": "Готово",
"loginReadTermsNotification": "Сначала прочитайте условия использования",
"loginSpoofRedacted": "Подмена данных", "loginSpoofRedacted": "Подмена данных",
"loginProxy": "Прокси", "loginProxy": "Прокси",
"loginChangeServer": "Смена сервера", "loginChangeServer": "Смена сервера",
@@ -238,6 +237,7 @@
"notificationsSaveFailed": "Не удалось сохранить: {error}", "notificationsSaveFailed": "Не удалось сохранить: {error}",
"notificationsFkmAlreadyHasFcm": "А зачем? У тебя уже FCM.", "notificationsFkmAlreadyHasFcm": "А зачем? У тебя уже FCM.",
"notificationsFkmDownloadFcm": "Установите FCM версию с официального источника", "notificationsFkmDownloadFcm": "Установите FCM версию с официального источника",
"notificationsFkmIosUnsupported": "На iOS пуш-уведомления пока недоступны",
"notificationsTitle": "Уведомления", "notificationsTitle": "Уведомления",
"notificationsFkmSectionTitle": "FKM", "notificationsFkmSectionTitle": "FKM",
"notificationsFkmEnableLabel": "Включить уведомления", "notificationsFkmEnableLabel": "Включить уведомления",