fix/refactor: IOS тоже члены общества!!! Не полноценные пока что но все-же. Теперь при нажатии по кнопке входа bottom sheet с условиями открывается сам.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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')];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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": "Включить уведомления",
|
||||||
|
|||||||
Reference in New Issue
Block a user