Import IKAR messenger client and server

This commit is contained in:
sevenhill
2026-10-07 22:06:07 +03:00
commit 69ea5091eb
1951 changed files with 424546 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
app/key.properties
app/qlyra-release.jks
**/*.keystore
**/*.jks
+190
View File
@@ -0,0 +1,190 @@
Copyright (c) 2005-2014, The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+190
View File
@@ -0,0 +1,190 @@
import java.util.Properties
import java.io.FileInputStream
import java.security.MessageDigest
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import javax.inject.Inject
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services") apply false
}
if (file("google-services.json").exists()) { apply(plugin = "com.google.gms.google-services") }
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file(
providers.gradleProperty("releaseSigningProperties").getOrElse("key.properties")
)
val hasReleaseSigning = keystorePropertiesFile.exists()
if (hasReleaseSigning) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
gradle.taskGraph.whenReady {
if (allTasks.any { it.project == project && it.name.contains("Release", ignoreCase = true) }) {
check(hasReleaseSigning) { "Release signing requires android/key.properties" }
for (key in listOf("keyAlias", "keyPassword", "storeFile", "storePassword")) {
check(!keystoreProperties.getProperty(key).isNullOrBlank()) { "Missing release signing property: $key" }
}
check(file(keystoreProperties.getProperty("storeFile")).isFile) { "Release keystore does not exist" }
}
}
android {
namespace = "ru.ikar.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "ru.ikar.app"
minSdk = maxOf(flutter.minSdkVersion, 23)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
flavorDimensions += "distribution"
productFlavors {
create("ikar") {
dimension = "distribution"
isDefault = true
applicationId = "ru.ikar.app"
}
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
signingConfig = if (hasReleaseSigning) {
signingConfigs.getByName("release")
} else null
}
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
flutter {
source = "../.."
}
abstract class StageFlutterJni : DefaultTask() {
@get:InputDirectory
abstract val inputDirectory: DirectoryProperty
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@get:Inject
abstract val fileSystem: FileSystemOperations
@TaskAction
fun stage() {
fileSystem.sync {
from(inputDirectory)
into(outputDirectory)
}
}
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val capitalized = variantName.replaceFirstChar { it.uppercase() }
val stage = tasks.register<StageFlutterJni>("stage${capitalized}FlutterJni") {
dependsOn("copyJniLibsflutterBuild$capitalized")
inputDirectory.set(layout.buildDirectory.dir("intermediates/flutter/$variantName/jniLibs"))
outputDirectory.set(layout.buildDirectory.dir("intermediates/qlyra_flutter_jni/$variantName"))
}
variant.sources.jniLibs?.addGeneratedSourceDirectory(stage) { it.outputDirectory }
}
}
android.applicationVariants.all {
if (buildType.name == "release") {
val variantName = name
val capitalized = variantName.replaceFirstChar { it.uppercase() }
val generated = layout.buildDirectory.dir("intermediates/flutter/$variantName/jniLibs")
val merged = layout.buildDirectory.dir(
"intermediates/merged_native_libs/$variantName/merge${capitalized}NativeLibs/out/lib"
)
val verifyJni = tasks.register("verify${capitalized}FlutterJni") {
dependsOn("merge${capitalized}NativeLibs", "copyJniLibsflutterBuild$capitalized")
doLast {
val root = generated.get().asFile
val libraries = root.walkTopDown().filter { it.isFile && it.extension == "so" }.toList()
check(libraries.any { it.name == "libapp.so" }) { "Compiled Flutter library is missing" }
for (appLibrary in libraries.filter { it.name == "libapp.so" }) {
val abi = appLibrary.parentFile.name
val runtime = merged.get().asFile.resolve("$abi/libfjs.so")
check(runtime.isFile && runtime.length() > 0) {
"Plugin runtime is missing for $abi; inspect the fjs/cargokit build log"
}
val crypto = merged.get().asFile.resolve("$abi/libkomet_crypto.so")
check(crypto.isFile && crypto.length() > 0) {
"E2EE crypto library is missing for $abi"
}
val renderer = merged.get().asFile.resolve("$abi/libkomet_tlottie.so")
check(renderer.isFile && renderer.length() > 0) {
"Tlottie renderer is missing for $abi; inspect the cargokit build log"
}
}
for (library in libraries) {
val relative = library.relativeTo(root)
val packaged = merged.get().asFile.resolve(relative)
check(packaged.isFile) { "Flutter library was not merged: $relative" }
val expected = MessageDigest.getInstance("SHA-256").digest(library.readBytes())
val actual = MessageDigest.getInstance("SHA-256").digest(packaged.readBytes())
check(expected.contentEquals(actual)) { "Stale Flutter library in package: $relative" }
}
logger.lifecycle("Verified fresh Flutter JNI for $variantName (${libraries.size} libraries)")
}
}
tasks.matching { it.name == "strip${capitalized}DebugSymbols" }.configureEach {
dependsOn(verifyJni)
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation("androidx.media3:media3-transformer:1.9.3")
implementation("androidx.media3:media3-effect:1.9.3")
implementation("androidx.media3:media3-common:1.9.3")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.1")
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+207
View File
@@ -0,0 +1,207 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<uses-permission android:name="com.huawei.android.launcher.permission.CHANGE_BADGE"/>
<uses-permission android:name="com.hihonor.android.launcher.permission.CHANGE_BADGE"/>
<application
android:label="ИКАР"
android:name="ru.ikar.app.QlyraApplication"
android:enableOnBackInvokedCallback="true"
android:allowBackup="false"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher">
<activity
android:name="ru.ikar.app.MainActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="ИКАР"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity-alias
android:name="ru.ikar.app.MinimalIcon"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="ИКАР"
android:targetActivity="ru.ikar.app.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name="ru.ikar.app.IntentEntry"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="ИКАР"
android:targetActivity="ru.ikar.app.MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="ikar"/>
</intent-filter>
<intent-filter android:label="@string/share_target_label">
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
<data android:mimeType="image/*"/>
<data android:mimeType="video/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="application/*"/>
</intent-filter>
<intent-filter android:label="@string/share_target_label">
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
<data android:mimeType="video/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="application/*"/>
</intent-filter>
</activity-alias>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<service
android:name=".UploadForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:name=".NfcHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/qlyra_nfc_apdu"/>
</service>
<service
android:name=".QlyraFcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name=".CallForegroundService"
android:foregroundServiceType="microphone|mediaProjection"
android:exported="false" />
<service
android:name=".FkmService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="User-enabled option that keeps the app's own authenticated connection to the messaging service open solely to deliver incoming message and call notifications. Firebase Cloud Messaging cannot be used for this app: push delivery is operated by the third-party messaging service, which sends pushes only to its own FCM sender, so the app cannot receive them. The service is off by default, is started only after the user turns it on, carries a persistent notification, and can be stopped from that notification at any time." />
</service>
<receiver
android:name=".FkmDisableReceiver"
android:exported="false" />
<receiver
android:name=".FkmBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<receiver
android:name=".CallActionReceiver"
android:exported="false" />
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="qlyra_messages" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<provider android:authorities="com.huawei.android.launcher.settings"/>
<provider android:authorities="com.hihonor.android.launcher.settings"/>
</queries>
</manifest>
@@ -0,0 +1,357 @@
package ru.ikar.app
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothGattServerCallback
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import java.util.UUID
class BleContactExchange(private val context: Context) {
companion object {
const val LOG_TAG = "BleExchange"
val SERVICE_UUID: UUID = UUID.fromString("f04b4f4d-4554-3100-0000-000000000001")
val CHAR_UUID: UUID = UUID.fromString("f04b4f4d-4554-3100-0000-000000000002")
const val MFG_ID = 0x4B4D
}
var onReceived: ((Long, Long) -> Unit)? = null
var onSent: ((Long) -> Unit)? = null
var onError: ((String) -> Unit)? = null
private val main = Handler(Looper.getMainLooper())
private val manager: BluetoothManager? =
context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
private val adapter: BluetoothAdapter? = manager?.adapter
private var gattServer: BluetoothGattServer? = null
private var advertiser: BluetoothLeAdvertiser? = null
private var scanner: BluetoothLeScanner? = null
private var scanCallback: ScanCallback? = null
private var advertiseCallback: AdvertiseCallback? = null
private var clientGatt: BluetoothGatt? = null
@Volatile private var selfId: Long = 0L
@Volatile private var selfSession: String = ""
@Volatile private var selfPhone: Long = 0L
@Volatile private var peerIdForWrite: Long = 0L
@Volatile private var connecting = false
@Volatile private var running = false
fun start(selfId: Long, selfSession: String, selfPhone: Long) {
val adapter = this.adapter
if (adapter == null || !adapter.isEnabled) {
emitError("bluetooth_off")
return
}
this.selfId = selfId
this.selfSession = selfSession
this.selfPhone = selfPhone
running = true
startGattServer()
}
fun connectTo(peerSession: String, peerId: Long) {
if (!running || connecting) return
peerIdForWrite = peerId
startScan(peerSession)
}
fun stop() {
running = false
connecting = false
stopScan()
stopAdvertising()
try {
clientGatt?.disconnect()
clientGatt?.close()
} catch (e: Exception) {
Log.w(LOG_TAG, "client close: ${e.message}")
}
clientGatt = null
try {
gattServer?.close()
} catch (e: Exception) {
Log.w(LOG_TAG, "server close: ${e.message}")
}
gattServer = null
}
private fun startGattServer() {
val server = try {
manager?.openGattServer(context, serverCallback)
} catch (e: SecurityException) {
emitError("permission")
return
}
if (server == null) {
emitError("gatt_unavailable")
return
}
gattServer = server
val characteristic = BluetoothGattCharacteristic(
CHAR_UUID,
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
BluetoothGattCharacteristic.PERMISSION_WRITE,
)
val service = BluetoothGattService(
SERVICE_UUID,
BluetoothGattService.SERVICE_TYPE_PRIMARY,
)
service.addCharacteristic(characteristic)
try {
server.addService(service)
} catch (e: SecurityException) {
emitError("permission")
}
}
private fun startAdvertising() {
val advertiser = adapter?.bluetoothLeAdvertiser
if (advertiser == null) {
Log.w(LOG_TAG, "advertising unsupported on this device")
return
}
this.advertiser = advertiser
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(false)
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.addManufacturerData(MFG_ID, hexToBytes(selfSession))
.build()
val callback = object : AdvertiseCallback() {
override fun onStartFailure(errorCode: Int) {
Log.w(LOG_TAG, "advertise failed: $errorCode")
}
}
advertiseCallback = callback
try {
advertiser.startAdvertising(settings, data, callback)
} catch (e: SecurityException) {
emitError("permission")
}
}
private fun stopAdvertising() {
val callback = advertiseCallback ?: return
try {
advertiser?.stopAdvertising(callback)
} catch (e: Exception) {
Log.w(LOG_TAG, "stopAdvertising: ${e.message}")
}
advertiseCallback = null
}
private fun startScan(peerSession: String) {
val scanner = adapter?.bluetoothLeScanner
if (scanner == null) {
emitError("scan_unavailable")
return
}
this.scanner = scanner
val target = peerSession.lowercase()
val filters = listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(SERVICE_UUID))
.build(),
)
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
handleScanResult(result, target)
}
override fun onBatchScanResults(results: MutableList<ScanResult>?) {
results?.forEach { handleScanResult(it, target) }
}
override fun onScanFailed(errorCode: Int) {
Log.w(LOG_TAG, "scan failed: $errorCode")
emitError("scan_failed")
}
}
scanCallback = callback
try {
scanner.startScan(filters, settings, callback)
} catch (e: SecurityException) {
emitError("permission")
}
}
private fun stopScan() {
val callback = scanCallback ?: return
try {
scanner?.stopScan(callback)
} catch (e: Exception) {
Log.w(LOG_TAG, "stopScan: ${e.message}")
}
scanCallback = null
}
private fun handleScanResult(result: ScanResult?, target: String) {
if (result == null || connecting) return
val mfg = result.scanRecord?.getManufacturerSpecificData(MFG_ID) ?: return
if (bytesToHex(mfg).lowercase() != target) return
connecting = true
stopScan()
connectGatt(result.device)
}
private fun connectGatt(device: BluetoothDevice) {
try {
clientGatt = device.connectGatt(
context,
false,
clientCallback,
BluetoothDevice.TRANSPORT_LE,
)
} catch (e: SecurityException) {
connecting = false
emitError("permission")
}
}
private val serverCallback = object : BluetoothGattServerCallback() {
override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
if (running) startAdvertising()
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice?,
requestId: Int,
characteristic: BluetoothGattCharacteristic?,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray?,
) {
if (responseNeeded) {
try {
gattServer?.sendResponse(
device,
requestId,
BluetoothGatt.GATT_SUCCESS,
offset,
null,
)
} catch (e: SecurityException) {
Log.w(LOG_TAG, "sendResponse: ${e.message}")
}
}
if (characteristic?.uuid != CHAR_UUID || value == null) return
val parts = String(value, Charsets.UTF_8).trim().split(":")
val peerId = parts.getOrNull(0)?.toLongOrNull() ?: return
val peerPhone = parts.getOrNull(1)?.toLongOrNull() ?: 0L
if (peerId > 0L) emitReceived(peerId, peerPhone)
}
}
private val clientCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
try {
gatt?.discoverServices()
} catch (e: SecurityException) {
Log.w(LOG_TAG, "discoverServices: ${e.message}")
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
try {
gatt?.close()
} catch (e: Exception) {
Log.w(LOG_TAG, "gatt close: ${e.message}")
}
if (gatt == clientGatt) clientGatt = null
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
if (gatt == null || status != BluetoothGatt.GATT_SUCCESS) {
gatt?.disconnect()
return
}
val characteristic = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHAR_UUID)
if (characteristic == null) {
gatt.disconnect()
return
}
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = "$selfId:$selfPhone".toByteArray(Charsets.UTF_8)
try {
@Suppress("DEPRECATION")
gatt.writeCharacteristic(characteristic)
} catch (e: SecurityException) {
Log.w(LOG_TAG, "writeCharacteristic: ${e.message}")
gatt.disconnect()
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int,
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val id = peerIdForWrite
if (id > 0L) emitSent(id)
}
gatt?.disconnect()
}
}
private fun emitReceived(id: Long, phone: Long) {
main.post { onReceived?.invoke(id, phone) }
}
private fun emitSent(id: Long) {
main.post { onSent?.invoke(id) }
}
private fun emitError(reason: String) {
main.post { onError?.invoke(reason) }
}
private fun hexToBytes(hex: String): ByteArray {
val clean = if (hex.length % 2 == 0) hex else "0$hex"
val out = ByteArray(clean.length / 2)
for (i in out.indices) {
out[i] = clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return out
}
private fun bytesToHex(bytes: ByteArray): String {
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) sb.append("%02x".format(b))
return sb.toString()
}
}
@@ -0,0 +1,167 @@
package ru.ikar.app
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.ServiceCompat
object CallState {
@Volatile
var inCall = false
}
class CallForegroundService : Service() {
companion object {
const val ACTION_START = "ru.ikar.app.CALL_ONGOING_START"
const val ACTION_STOP = "ru.ikar.app.CALL_ONGOING_STOP"
const val ACTION_SCREEN_SHARE = "ru.ikar.app.CALL_SCREEN_SHARE"
const val EXTRA_SCREEN_SHARE = "screenShare"
const val ONGOING_ID = 424243
@Volatile
var screenShare = false
@Volatile
private var running: CallForegroundService? = null
fun setScreenShare(ctx: Context, enabled: Boolean, caller: String) {
screenShare = enabled
val intent = Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_SCREEN_SHARE
putExtra(CallConst.EXTRA_CALLER, caller)
putExtra(EXTRA_SCREEN_SHARE, enabled)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("QlyraFcm", "screen share FGS update failed: ${e.message}")
}
}
fun start(ctx: Context, caller: String) {
CallState.inCall = true
val intent = Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_START
putExtra(CallConst.EXTRA_CALLER, caller)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("QlyraFcm", "ongoing FGS start failed: ${e.message}")
}
}
fun stop(ctx: Context) {
CallState.inCall = false
screenShare = false
val service = running
if (service != null) {
service.shutdown()
return
}
NotificationManagerCompat.from(ctx).cancel(ONGOING_ID)
try {
ctx.startService(
Intent(ctx, CallForegroundService::class.java).apply {
action = ACTION_STOP
},
)
} catch (_: Exception) {
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
running = this
}
override fun onDestroy() {
if (running === this) running = null
CallState.inCall = false
super.onDestroy()
}
private fun shutdown() {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> shutdown()
ACTION_SCREEN_SHARE -> {
screenShare = intent.getBooleanExtra(EXTRA_SCREEN_SHARE, false)
val caller = intent.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок"
CallNotifier.ensureChannel(this)
startAsForeground(caller)
}
else -> {
val caller = intent?.getStringExtra(CallConst.EXTRA_CALLER) ?: "Звонок"
CallNotifier.ensureChannel(this)
startAsForeground(caller)
}
}
return START_NOT_STICKY
}
private fun startAsForeground(caller: String) {
val immutable = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE
val open = LaunchIntents.app(this).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pi = android.app.PendingIntent.getActivity(this, 4, open, immutable)
val hangup = android.app.PendingIntent.getBroadcast(
this, 5,
Intent(this, CallActionReceiver::class.java).apply {
action = CallConst.ACTION_HANGUP
},
immutable,
)
val person = Person.Builder().setName(caller).build()
val notif = NotificationCompat.Builder(this, CallConst.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setColorized(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pi)
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, hangup))
.build()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
if (screenShare) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
startForeground(ONGOING_ID, notif, types)
} else {
startForeground(ONGOING_ID, notif)
}
} catch (e: Exception) {
Log.w("QlyraFcm", "startForeground failed: ${e.message}")
stopSelf()
}
}
}
@@ -0,0 +1,339 @@
package ru.ikar.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.Ringtone
import android.media.RingtoneManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import org.json.JSONObject
object AppState {
@Volatile
var resumed = false
}
object CallEvents {
@Volatile
var sink: io.flutter.plugin.common.EventChannel.EventSink? = null
private val main = Handler(Looper.getMainLooper())
fun emit(action: String) {
val s = sink ?: return
main.post {
try {
s.success(mapOf("action" to action))
} catch (_: Exception) {
}
}
}
}
object CallConst {
const val CHANNEL_ID = "qlyra_calls"
const val CHANNEL_NAME = "Звонки"
const val NOTIF_ID = 424242
const val ACCENT = 0xFF7C6BF0.toInt()
const val EXTRA_CALL = "qlyra_call"
const val EXTRA_ACTION = "qlyra_call_action"
const val EXTRA_DECLINE_PAYLOAD = "qlyra_decline_payload"
const val EXTRA_NOTIF_ID = "qlyra_notif_id"
const val EXTRA_CALLER = "qlyra_caller"
const val ACTION_RING = "ring"
const val ACTION_ANSWER = "answer"
const val ACTION_DECLINE = "ru.ikar.app.CALL_DECLINE"
const val ACTION_HANGUP = "ru.ikar.app.CALL_HANGUP"
const val FLN_RECEIVER =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
const val FLN_ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED"
}
object CallNotifier {
fun showIncoming(ctx: Context, data: Map<String, String>) {
if (AppState.resumed) {
Log.d("QlyraFcm", "call push suppressed (app foreground)")
return
}
val name = data["userName"] ?: data["title"] ?: "Неизвестный"
val callerId = data["suid"] ?: data["callerId"] ?: ""
val conversationId = data["conversationId"] ?: data["vcId"] ?: ""
val vcp = data["vcp"] ?: ""
val account = data["c"] ?: ""
val callJson = JSONObject(data as Map<*, *>).toString()
Log.d("QlyraFcm", "showIncoming caller=$callerId conv=$conversationId keys=${data.keys}")
ensureChannel(ctx)
val fullScreen = launchIntent(ctx, callJson, CallConst.ACTION_RING, name, 1)
val answer = launchIntent(ctx, callJson, CallConst.ACTION_ANSWER, name, 2)
val decline = declineIntent(ctx, vcp, conversationId, account)
val video = isVideo(data)
fun notification(avatar: Bitmap, update: Boolean): Notification {
val person = Person.Builder()
.setName(name)
.setKey(callerId)
.setIcon(IconCompat.createWithBitmap(avatar))
.build()
return NotificationCompat.Builder(ctx, CallConst.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setColorized(true)
.setOngoing(true)
.setOnlyAlertOnce(update)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(fullScreen)
.setFullScreenIntent(fullScreen, true)
.setStyle(
NotificationCompat.CallStyle.forIncomingCall(person, decline, answer)
.setIsVideo(video),
)
.build()
}
val manager = NotificationManagerCompat.from(ctx)
manager.notify(CallConst.NOTIF_ID, notification(NotifAvatars.initials(name), update = false))
CallRinger.start(ctx, conversationId)
val photo = NotifAvatars.photo(ctx, callerId) ?: return
if (!isIncomingShowing(ctx)) return
manager.notify(CallConst.NOTIF_ID, notification(photo, update = true))
}
private fun isIncomingShowing(ctx: Context): Boolean = try {
ctx.getSystemService(NotificationManager::class.java)
?.activeNotifications
?.any { it.id == CallConst.NOTIF_ID } == true
} catch (e: Exception) {
false
}
fun finishCall(ctx: Context, data: Map<String, String>) {
Log.d("QlyraFcm", "finishCall keys=${data.keys}")
CallRinger.stop()
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
CallEvents.emit("ended")
}
private fun isVideo(data: Map<String, String>): Boolean {
val t = data["type"] ?: data["callType"]
if (t == "VIDEO") return true
val iv = data["iv"]
return iv == "true" || iv == "1"
}
private fun launchIntent(
ctx: Context,
callJson: String,
action: String,
caller: String,
requestCode: Int,
): PendingIntent {
val intent = LaunchIntents.app(ctx).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(CallConst.EXTRA_CALL, callJson)
putExtra(CallConst.EXTRA_ACTION, action)
putExtra(CallConst.EXTRA_CALLER, caller)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
return PendingIntent.getActivity(ctx, requestCode, intent, flags)
}
private fun declineIntent(
ctx: Context,
vcp: String,
conversationId: String,
account: String,
): PendingIntent {
val payload = JSONObject()
.put("vcp", vcp)
.put("conversationId", conversationId)
.put("c", account.toLongOrNull() ?: account)
.toString()
val intent = Intent(ctx, CallActionReceiver::class.java).apply {
action = CallConst.ACTION_DECLINE
putExtra(CallConst.EXTRA_DECLINE_PAYLOAD, payload)
putExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags = flags or PendingIntent.FLAG_IMMUTABLE
}
return PendingIntent.getBroadcast(ctx, 3, intent, flags)
}
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(CallConst.CHANNEL_ID) != null) return
val channel = NotificationChannel(
CallConst.CHANNEL_ID,
CallConst.CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Входящие звонки"
setSound(null, null)
enableVibration(false)
setBypassDnd(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}
mgr.createNotificationChannel(channel)
}
}
object CallRinger {
private val handler = Handler(Looper.getMainLooper())
private var ringtone: Ringtone? = null
private var vibrator: Vibrator? = null
private var wakeLock: PowerManager.WakeLock? = null
private var timeout: Runnable? = null
@Volatile
var activeConversationId: String? = null
private set
fun start(ctx: Context, conversationId: String) {
stop()
activeConversationId = conversationId
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "qlyra:call").apply {
setReferenceCounted(false)
acquire(60_000L)
}
val audio = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val mode = audio.ringerMode
if (mode == AudioManager.RINGER_MODE_NORMAL) {
playRingtone(ctx)
vibrate(ctx)
} else if (mode == AudioManager.RINGER_MODE_VIBRATE) {
vibrate(ctx)
}
val t = Runnable { onTimeout(ctx) }
timeout = t
handler.postDelayed(t, 45_000L)
}
fun stop() {
timeout?.let { handler.removeCallbacks(it) }
timeout = null
try {
ringtone?.stop()
} catch (_: Exception) {
}
ringtone = null
try {
vibrator?.cancel()
} catch (_: Exception) {
}
vibrator = null
try {
wakeLock?.let { if (it.isHeld) it.release() }
} catch (_: Exception) {
}
wakeLock = null
activeConversationId = null
}
private fun onTimeout(ctx: Context) {
stop()
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
}
private fun playRingtone(ctx: Context) {
try {
val uri = RingtoneManager.getActualDefaultRingtoneUri(
ctx, RingtoneManager.TYPE_RINGTONE,
) ?: RingtoneManager.getActualDefaultRingtoneUri(
ctx, RingtoneManager.TYPE_NOTIFICATION,
) ?: return
val rt = RingtoneManager.getRingtone(ctx, uri) ?: return
rt.audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
rt.isLooping = true
}
rt.play()
ringtone = rt
} catch (e: Exception) {
Log.w("QlyraFcm", "ringtone failed: ${e.message}")
}
}
private fun vibrate(ctx: Context) {
try {
val vib = ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (!vib.hasVibrator()) return
val pattern = longArrayOf(0, 1000, 1000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vib.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION")
vib.vibrate(pattern, 0)
}
vibrator = vib
} catch (e: Exception) {
Log.w("QlyraFcm", "vibrate failed: ${e.message}")
}
}
}
class CallActionReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == CallConst.ACTION_HANGUP) {
CallEvents.emit("hangup")
return
}
if (intent.action != CallConst.ACTION_DECLINE) return
CallRinger.stop()
val notifId = intent.getIntExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
NotificationManagerCompat.from(ctx).cancel(notifId)
val payload = intent.getStringExtra(CallConst.EXTRA_DECLINE_PAYLOAD) ?: return
val fln = Intent(CallConst.FLN_ACTION_TAPPED).apply {
setClassName(ctx, CallConst.FLN_RECEIVER)
putExtra("notificationId", notifId)
putExtra("actionId", "call_decline")
putExtra("payload", payload)
putExtra("cancelNotification", true)
}
try {
ctx.sendBroadcast(fln)
} catch (e: Exception) {
Log.w("QlyraFcm", "decline broadcast failed: ${e.message}")
}
}
}
@@ -0,0 +1,20 @@
package ru.ikar.app
import android.content.Intent
import io.flutter.plugin.common.EventChannel
object ChatNotifications {
const val EXTRA_CHAT = "qlyra_chat"
@Volatile
var activeChatId: Long = 0L
@Volatile
var sink: EventChannel.EventSink? = null
fun isDisplayed(chatId: Long): Boolean =
AppState.resumed && activeChatId == chatId
fun chatIdFrom(intent: Intent?): Long =
intent?.getLongExtra(EXTRA_CHAT, 0L) ?: 0L
}
@@ -0,0 +1,202 @@
package ru.ikar.app
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong
object ClipboardMedia {
private const val TAG = "ClipboardMedia"
private const val CHANNEL = "ru.ikar.app/clipboard"
private const val CACHE_DIR = "clipboard_in"
private const val MAX_ITEMS = 20
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
private const val MAX_NAME = 96
private val textualTypes = setOf(
ClipDescription.MIMETYPE_TEXT_PLAIN,
ClipDescription.MIMETYPE_TEXT_HTML,
ClipDescription.MIMETYPE_TEXT_URILIST,
ClipDescription.MIMETYPE_TEXT_INTENT,
)
private val executor = Executors.newSingleThreadExecutor()
private val handler = Handler(Looper.getMainLooper())
private val seq = AtomicLong(0L)
fun attach(engine: FlutterEngine, context: Context) {
val app = context.applicationContext
MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"hasMedia" -> result.success(hasMedia(app))
"read" -> read(app, result)
else -> result.notImplemented()
}
}
}
private fun clipboard(context: Context): ClipboardManager? =
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
private fun hasMedia(context: Context): Boolean {
val description = try {
clipboard(context)?.primaryClipDescription
} catch (e: Exception) {
Log.w(TAG, "clipboard description unavailable: $e")
null
} ?: return false
for (index in 0 until description.mimeTypeCount) {
if (description.getMimeType(index) !in textualTypes) return true
}
return false
}
private fun read(context: Context, result: MethodChannel.Result) {
val clip = try {
clipboard(context)?.primaryClip
} catch (e: Exception) {
Log.w(TAG, "clipboard unavailable: $e")
null
}
if (clip == null || clip.itemCount == 0) {
result.success(null)
return
}
val uris = ArrayList<Uri>(clip.itemCount)
for (index in 0 until minOf(clip.itemCount, MAX_ITEMS)) {
val uri = clip.getItemAt(index).uri ?: continue
if (uri.scheme != ContentResolver.SCHEME_CONTENT &&
uri.scheme != ContentResolver.SCHEME_FILE
) {
continue
}
uris.add(uri)
}
if (uris.isEmpty()) {
result.success(null)
return
}
val declaredMime = clip.description?.getMimeType(0)
executor.execute {
val paths = materialize(context, uris, declaredMime)
handler.post { result.success(if (paths.isEmpty()) null else mapOf("files" to paths)) }
}
}
private fun materialize(context: Context, uris: List<Uri>, declaredMime: String?): List<String> {
val root = File(context.cacheDir, CACHE_DIR)
prune(root)
val paths = ArrayList<String>(uris.size)
for (uri in uris) {
val copied = copyToCache(context, root, uri, declaredMime) ?: continue
paths.add(copied)
}
return paths
}
private fun copyToCache(
context: Context,
root: File,
uri: Uri,
declaredMime: String?,
): String? {
val resolver = context.contentResolver
val dir = File(root, "${System.currentTimeMillis()}_${seq.incrementAndGet()}")
return try {
val mime = resolveMime(resolver, uri, declaredMime)
dir.mkdirs()
val target = File(dir, fileName(resolver, uri, mime))
resolver.openInputStream(uri).use { input ->
if (input == null) {
dir.deleteRecursively()
return null
}
target.outputStream().use { output -> input.copyTo(output) }
}
if (target.length() <= 0L) {
dir.deleteRecursively()
null
} else {
target.absolutePath
}
} catch (e: Exception) {
Log.w(TAG, "cannot read $uri: $e")
dir.deleteRecursively()
null
}
}
private fun resolveMime(resolver: ContentResolver, uri: Uri, declaredMime: String?): String {
val fromResolver = resolver.getType(uri)
if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver
val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
if (!extension.isNullOrBlank()) {
val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())
if (!guessed.isNullOrBlank()) return guessed
}
if (!declaredMime.isNullOrBlank() && declaredMime != "*/*") return declaredMime
return "application/octet-stream"
}
private fun fileName(resolver: ContentResolver, uri: Uri, mime: String): String {
val declared = queryDisplayName(resolver, uri)
?: uri.lastPathSegment?.substringAfterLast('/')
val base = sanitize(declared.orEmpty())
.ifEmpty { "paste_${System.currentTimeMillis()}" }
val current = base.substringAfterLast('.', "")
if (current.length in 1..5 && current.all { it.isLetterOrDigit() }) return base
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
return if (extension.isNullOrBlank()) "$base.bin" else "$base.$extension"
}
private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? {
if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment
return try {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
}
} catch (e: Exception) {
Log.w(TAG, "display name for $uri: $e")
null
}
}
private fun sanitize(name: String): String {
val cleaned = name
.replace(Regex("[\\\\/:*?\"<>|\\x00-\\x1F]"), "_")
.trim()
.trimStart('.')
return if (cleaned.length <= MAX_NAME) cleaned else cleaned.takeLast(MAX_NAME)
}
private fun prune(root: File) {
try {
if (!root.isDirectory) {
root.mkdirs()
return
}
val cutoff = System.currentTimeMillis() - RETENTION_MS
root.listFiles()?.forEach { entry ->
if (entry.lastModified() < cutoff) entry.deleteRecursively()
}
} catch (e: Exception) {
Log.w(TAG, "cache cleanup failed: $e")
}
}
}
@@ -0,0 +1,175 @@
package ru.ikar.app
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
object FkmChannel {
private const val NAME = "ru.ikar.app/fkm"
const val NOTIF_PERMS_REQUEST = 7713
private val main = Handler(Looper.getMainLooper())
private val worker = Executors.newSingleThreadExecutor()
private var channel: MethodChannel? = null
private var permResult: MethodChannel.Result? = null
fun attach(engine: FlutterEngine, activity: Activity) {
val ctx = activity.applicationContext
FkmState.restore(ctx)
FkmService.stop(ctx)
val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME)
channel = ch
ch.setMethodCallHandler { call, result ->
when (call.method) {
"isEnabled" -> result.success(FkmState.enabled)
"setEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
FkmState.applyEnabled(ctx, enabled)
if (FkmState.enabled) FkmService.start(ctx) else FkmService.stop(ctx)
result.success(null)
}
"setConnected" -> {
FkmState.connected = FkmState.enabled && (call.argument<Boolean>("connected") ?: false)
FkmService.refresh(ctx)
result.success(null)
}
"showMessage" -> result.success(deliver(ctx, call, "showMessage"))
"showCall" -> result.success(deliver(ctx, call, "showCall"))
"editMessage" -> result.success(
update(ctx, call, "editMessage") { notifier, data ->
notifier.editMessage(data)
},
)
"removeMessage" -> result.success(
update(ctx, call, "removeMessage") { notifier, data ->
notifier.removeMessage(data)
},
)
"hasNotificationPermission" ->
result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled())
"requestNotificationPermission" -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
NotificationManagerCompat.from(ctx).areNotificationsEnabled()
) {
result.success(
NotificationManagerCompat.from(ctx).areNotificationsEnabled(),
)
} else {
permResult?.success(false)
permResult = result
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIF_PERMS_REQUEST,
)
}
}
"isIgnoringBatteryOptimizations" -> {
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
result.success(power.isIgnoringBatteryOptimizations(ctx.packageName))
}
"requestIgnoreBatteryOptimizations" -> {
try {
activity.startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${ctx.packageName}"),
),
)
} catch (e: Exception) {
Log.w("Fkm", "battery settings failed: ${e.message}")
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
// Отрисовка тянет аватарку по сети — только не на главном потоке.
private fun deliver(ctx: Context, call: MethodCall, tag: String): Boolean {
if (!FkmState.enabled) return false
val data = call.argument<Map<String, String>>("data") ?: return false
worker.execute {
try {
QlyraNotifier(ctx).handle(data)
FkmState.countDelivered(ctx)
FkmService.refresh(ctx)
} catch (e: Exception) {
Log.w("Fkm", "$tag failed: ${e.message}")
}
}
return true
}
// Правка и удаление ничего не «доставляют» — счётчик они не трогают.
private fun update(
ctx: Context,
call: MethodCall,
tag: String,
action: (QlyraNotifier, Map<String, String>) -> Unit,
): Boolean {
val data = call.argument<Map<String, String>>("data") ?: return false
worker.execute {
try {
action(QlyraNotifier(ctx), data)
} catch (e: Exception) {
Log.w("Fkm", "$tag failed: ${e.message}")
}
}
return true
}
fun detach() {
channel?.setMethodCallHandler(null)
channel = null
permResult?.success(false)
permResult = null
}
fun onPermissionResult(grantResults: IntArray) {
val pending = permResult ?: return
permResult = null
pending.success(
grantResults.isNotEmpty() &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED },
)
}
fun notifyDisabled() {
main.post {
try {
channel?.invokeMethod("disabled", null)
} catch (e: Exception) {
Log.w("Fkm", "notifyDisabled failed: ${e.message}")
}
}
}
}
@@ -0,0 +1,259 @@
package ru.ikar.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
object FkmState {
private const val ALLOWED = false
private const val PREFS = "qlyra_fkm"
private const val KEY_ENABLED = "enabled"
private const val KEY_DELIVERED = "delivered"
@Volatile
var enabled = false
private set
@Volatile
var connected = false
@Volatile
var delivered = 0
private set
private fun prefs(ctx: Context) =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun restore(ctx: Context) {
// Clear the old preference too: upgrades and boot must keep FKM off.
applyEnabled(ctx, false)
}
fun applyEnabled(ctx: Context, value: Boolean) {
enabled = value && ALLOWED
// Счётчик обнуляем только на выключении, чтобы перезапуск приложения
// не сбрасывал накопленное.
if (!enabled) {
delivered = 0
connected = false
}
prefs(ctx).edit()
.putBoolean(KEY_ENABLED, enabled)
.putInt(KEY_DELIVERED, delivered)
.apply()
}
fun countDelivered(ctx: Context) {
delivered += 1
prefs(ctx).edit().putInt(KEY_DELIVERED, delivered).apply()
}
}
object FkmNotification {
const val CHANNEL_ID = "qlyra_fkm"
const val NOTIFICATION_ID = 424244
fun build(ctx: Context): Notification {
ensureChannel(ctx)
val status = if (FkmState.connected) {
ctx.getString(R.string.fkm_status_active)
} else {
ctx.getString(R.string.fkm_status_inactive)
}
val title = ctx.getString(R.string.fkm_title)
val text = ctx.getString(R.string.fkm_status_line, status, FkmState.delivered)
val immutable = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val open = PendingIntent.getActivity(
ctx,
0,
LaunchIntents.app(ctx)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
immutable,
)
val disable = PendingIntent.getBroadcast(
ctx,
1,
Intent(ctx, FkmDisableReceiver::class.java).apply {
action = FkmDisableReceiver.ACTION_DISABLE
},
immutable,
)
return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(CallConst.ACCENT)
.setContentTitle(title)
.setContentText(text)
.setStyle(
NotificationCompat.BigTextStyle()
.setBigContentTitle(title)
.bigText("$text\n\n${ctx.getString(R.string.fkm_explain)}"),
)
.setContentIntent(open)
.addAction(0, ctx.getString(R.string.fkm_disable), disable)
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
.build()
}
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = manager(ctx)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.fkm_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = ctx.getString(R.string.fkm_channel_description)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
enableLights(false)
},
)
}
fun manager(ctx: Context): NotificationManager =
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
class FkmService : Service() {
companion object {
fun start(ctx: Context) {
if (!FkmState.enabled) {
stop(ctx)
return
}
val intent = Intent(ctx, FkmService::class.java)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
} catch (e: Exception) {
Log.w("Fkm", "service start failed: ${e.message}")
}
}
// Перерисовка уже висящего уведомления, без перезапуска сервиса.
fun refresh(ctx: Context) {
if (!FkmState.enabled) return
try {
FkmNotification.manager(ctx)
.notify(FkmNotification.NOTIFICATION_ID, FkmNotification.build(ctx))
} catch (e: Exception) {
Log.w("Fkm", "notification refresh failed: ${e.message}")
}
}
fun stop(ctx: Context) {
try {
ctx.stopService(Intent(ctx, FkmService::class.java))
FkmNotification.manager(ctx).cancel(FkmNotification.NOTIFICATION_ID)
} catch (e: Exception) {
Log.w("Fkm", "service stop failed: ${e.message}")
}
}
}
private var inForeground = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
FkmState.restore(applicationContext)
FkmNotification.ensureChannel(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!FkmState.enabled) {
stopForeground(STOP_FOREGROUND_REMOVE)
inForeground = false
stopSelf()
return START_NOT_STICKY
}
goForeground()
return START_STICKY
}
override fun onDestroy() {
if (inForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
inForeground = false
}
super.onDestroy()
}
private fun goForeground() {
val notification = FkmNotification.build(this)
if (inForeground) {
FkmNotification.manager(this)
.notify(FkmNotification.NOTIFICATION_ID, notification)
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
FkmNotification.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(FkmNotification.NOTIFICATION_ID, notification)
}
inForeground = true
} catch (e: Exception) {
Log.w("Fkm", "startForeground failed: ${e.message}")
stopSelf()
}
}
}
class FkmDisableReceiver : BroadcastReceiver() {
companion object {
const val ACTION_DISABLE = "ru.ikar.app.FKM_DISABLE"
}
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != ACTION_DISABLE) return
val app = ctx.applicationContext
FkmState.applyEnabled(app, false)
FkmService.stop(app)
FkmChannel.notifyDisabled()
}
}
class FkmBootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val app = ctx.applicationContext
FkmState.restore(app)
if (!FkmState.enabled) return
// Движка после ребута нет — уведомление честно скажет «не активно»,
// пока приложение не откроют.
FkmState.connected = false
FkmService.start(app)
}
}
@@ -0,0 +1,55 @@
package ru.ikar.app
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.MessageDigest
import java.security.Signature
import java.security.spec.ECGenParameterSpec
object IkarDeviceAuth {
private const val aliasPrefix = "ikar_device_auth_p256_v1_"
private val lock = Any()
private fun store(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
fun register(engine: FlutterEngine) {
MethodChannel(engine.dartExecutor.binaryMessenger, "ru.ikar.app/device_auth").setMethodCallHandler { call, result ->
try {
synchronized(lock) {
val arguments = call.arguments as? Map<*, *> ?: emptyMap<String, String>()
val scope = (arguments["scope"] as? String ?: "").filter { it.isDigit() }
require(scope.length in 7..15) { "Phone scope required" }
val suffix = MessageDigest.getInstance("SHA-256").digest(scope.toByteArray()).take(16).joinToString("") { "%02x".format(it.toInt() and 255) }
val alias = aliasPrefix + suffix
val keys = store()
if (!keys.containsAlias(alias)) {
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").apply {
initialize(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256).build())
generateKeyPair()
}
}
when (call.method) {
"identity" -> {
val publicKey = keys.getCertificate(alias).publicKey.encoded
val id = MessageDigest.getInstance("SHA-256").digest(publicKey).take(16).joinToString("") { "%02x".format(it.toInt() and 255) }
result.success(mapOf("installationId" to id, "devicePublicKey" to Base64.encodeToString(publicKey, Base64.NO_WRAP)))
}
"sign" -> {
val signature = Signature.getInstance("SHA256withECDSA").apply {
initSign(keys.getKey(alias, null) as java.security.PrivateKey)
update(Base64.decode(arguments["challenge"] as String, Base64.DEFAULT))
}.sign()
result.success(Base64.encodeToString(signature, Base64.NO_WRAP))
}
else -> result.notImplemented()
}
}
} catch (error: Exception) { result.error("IKAR_DEVICE_AUTH", "Не удалось подтвердить ключ устройства", null) }
}
}
}
@@ -0,0 +1,16 @@
package ru.ikar.app
import android.content.Context
import android.content.Intent
object LaunchIntents {
fun app(ctx: Context): Intent {
val launcher = ctx.packageManager
.getLaunchIntentForPackage(ctx.packageName)?.component
return if (launcher != null) {
Intent().setComponent(launcher)
} else {
Intent(ctx, MainActivity::class.java)
}
}
}
@@ -0,0 +1,196 @@
package ru.ikar.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
object LauncherBadge {
private const val NAME = "ru.ikar.app/launcher_badge"
private const val TAG = "LauncherBadge"
private const val PREFS = "qlyra_badge"
private const val KEY_ENABLED = "enabled"
private const val KEY_COUNT = "count"
private const val KEY_BY_CHATS = "by_chats"
private const val KEY_CHATS = "chats"
private val AUTHORITIES = listOf(
"com.huawei.android.launcher.settings",
"com.hihonor.android.launcher.settings",
)
private val worker = Executors.newSingleThreadExecutor()
private var channel: MethodChannel? = null
@Volatile
private var live = false
@Volatile
private var resolvedAuthority: String? = null
@Volatile
private var resolved = false
fun attach(engine: FlutterEngine, ctx: Context) {
val app = ctx.applicationContext
val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME)
channel = ch
live = true
ch.setMethodCallHandler { call, result ->
when (call.method) {
"isSupported" -> result.success(authority(app) != null)
"sync" -> {
val chats = call.argument<List<Number>>("chats").orEmpty()
sync(
app,
enabled = call.argument<Boolean>("enabled") ?: false,
count = call.argument<Int>("count") ?: 0,
byChats = call.argument<Boolean>("byChats") ?: false,
chats = chats.map { it.toLong().toString() }.toSet(),
)
result.success(null)
}
else -> result.notImplemented()
}
}
}
fun detach() {
channel?.setMethodCallHandler(null)
channel = null
live = false
}
// Upgrade cleanup for the removed unread-counter setting. Ordinary message
// notifications return to their original channel; only the counter is cancelled.
fun clearRemovedCounter(ctx: Context) {
val app = ctx.applicationContext
if (!prefs(app).edit().clear().putBoolean(KEY_ENABLED, false)
.putInt(KEY_COUNT, 0).putLong("account", 0L).commit()) {
Log.w(TAG, "removed badge preferences could not be cleared")
}
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.cancel(424201)
worker.execute { show(app, 0, launcherClass(app)) }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
try {
val migrated = manager.getNotificationChannel("qlyra_messages_v2") ?: return
if (manager.getNotificationChannel("qlyra_messages") == null) {
manager.createNotificationChannel(NotificationChannel(
"qlyra_messages", migrated.name, migrated.importance,
).apply {
setShowBadge(true)
description = migrated.description
group = migrated.group
setSound(migrated.sound, migrated.audioAttributes)
vibrationPattern = migrated.vibrationPattern
enableVibration(migrated.shouldVibrate())
enableLights(migrated.shouldShowLights())
lightColor = migrated.lightColor
lockscreenVisibility = migrated.lockscreenVisibility
})
}
for (active in manager.activeNotifications) {
if (active.notification.channelId != "qlyra_messages_v2") continue
val notification = Notification.Builder.recoverBuilder(app, active.notification)
.setChannelId("qlyra_messages").setOnlyAlertOnce(true).build()
manager.notify(active.tag, active.id, notification)
}
} catch (e: Exception) {
Log.w(TAG, "message channel restore failed: ${e.javaClass.simpleName}")
}
}
fun countPush(ctx: Context, chatId: Long) {
if (live || chatId == 0L) return
worker.execute {
val p = prefs(ctx)
if (!p.getBoolean(KEY_ENABLED, true)) return@execute
val count = p.getInt(KEY_COUNT, 0)
val edit = p.edit()
if (p.getBoolean(KEY_BY_CHATS, false)) {
val chats = p.getStringSet(KEY_CHATS, null).orEmpty()
val key = chatId.toString()
if (key in chats) return@execute
edit.putStringSet(KEY_CHATS, chats + key)
}
edit.putInt(KEY_COUNT, count + 1).commit()
show(ctx, count + 1, launcherClass(ctx))
}
}
fun reapply(ctx: Context, launcherClass: String) {
val app = ctx.applicationContext
worker.execute { show(app, prefs(app).getInt(KEY_COUNT, 0), launcherClass) }
}
private fun sync(
ctx: Context,
enabled: Boolean,
count: Int,
byChats: Boolean,
chats: Set<String>,
) {
worker.execute {
prefs(ctx).edit()
.putBoolean(KEY_ENABLED, enabled)
.putInt(KEY_COUNT, count)
.putBoolean(KEY_BY_CHATS, byChats)
.putStringSet(KEY_CHATS, chats)
.commit()
show(ctx, count, launcherClass(ctx))
}
}
private fun show(ctx: Context, count: Int, launcherClass: String?) {
val authority = authority(ctx) ?: return
if (launcherClass == null) return
val extras = Bundle().apply {
putString("package", ctx.packageName)
putString("class", launcherClass)
putInt("badgenumber", count)
}
try {
ctx.contentResolver.call(
Uri.parse("content://$authority/badge/"),
"change_badge",
null,
extras,
)
} catch (e: Exception) {
Log.w(TAG, "change_badge failed: ${e.message}")
}
}
private fun authority(ctx: Context): String? {
if (!resolved) {
resolvedAuthority = AUTHORITIES.firstOrNull { providerExists(ctx, it) }
resolved = true
}
return resolvedAuthority
}
private fun providerExists(ctx: Context, authority: String): Boolean {
val pm = ctx.packageManager
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.resolveContentProvider(authority, PackageManager.ComponentInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
pm.resolveContentProvider(authority, 0)
}
return info != null
}
private fun launcherClass(ctx: Context): String? =
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.component?.className
private fun prefs(ctx: Context) =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
package ru.ikar.app
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.IOException
import java.util.concurrent.Executors
object MediaExport {
private const val TAG = "MediaExport"
private const val CHANNEL = "ru.ikar.app/media_export"
private const val FALLBACK_MIME = "application/octet-stream"
private const val BUFFER_SIZE = 256 * 1024
private const val CREATE_DOCUMENT_REQUEST = 7714
private class PendingExport(val source: File, val result: MethodChannel.Result)
private val main = Handler(Looper.getMainLooper())
private val worker = Executors.newSingleThreadExecutor()
private var pending: PendingExport? = null
fun attach(engine: FlutterEngine, activity: Activity) {
MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"saveAs" -> saveAs(
activity,
call.argument<String>("path"),
call.argument<String>("name"),
result,
)
else -> result.notImplemented()
}
}
}
fun onActivityResult(context: Context, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != CREATE_DOCUMENT_REQUEST) return
val export = pending ?: return
pending = null
val target = data?.data
if (resultCode != Activity.RESULT_OK || target == null) {
export.result.success(null)
return
}
val app = context.applicationContext
worker.execute {
try {
copy(app, export.source, target)
main.post { export.result.success(target.toString()) }
} catch (e: Exception) {
Log.w(TAG, "export to $target failed: $e")
discard(app, target)
main.post { export.result.error("WRITE_FAILED", e.message, null) }
}
}
}
private fun saveAs(
activity: Activity,
path: String?,
name: String?,
result: MethodChannel.Result,
) {
if (path.isNullOrEmpty() || name.isNullOrEmpty()) {
result.error("BAD_ARGS", "path and name required", null)
return
}
val source = File(path)
if (!source.isFile) {
result.error("NO_SOURCE", "source file missing", null)
return
}
if (pending != null) {
result.error("EXPORT_BUSY", "another document picker is open", null)
return
}
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeFor(name)
putExtra(Intent.EXTRA_TITLE, name)
}
pending = PendingExport(source, result)
try {
activity.startActivityForResult(intent, CREATE_DOCUMENT_REQUEST)
} catch (e: ActivityNotFoundException) {
pending = null
result.error("NO_PICKER", e.message, null)
}
}
private fun mimeFor(name: String): String {
val extension = name.substringAfterLast('.', "").lowercase()
if (extension.isEmpty()) return FALLBACK_MIME
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: FALLBACK_MIME
}
private fun copy(context: Context, source: File, target: Uri) {
val output = context.contentResolver.openOutputStream(target, "w")
?: throw IOException("cannot open $target")
output.use { sink ->
source.inputStream().use { it.copyTo(sink, BUFFER_SIZE) }
}
}
private fun discard(context: Context, target: Uri) {
try {
DocumentsContract.deleteDocument(context.contentResolver, target)
} catch (e: Exception) {
Log.w(TAG, "cannot remove partial export $target: $e")
}
}
}
@@ -0,0 +1,58 @@
package ru.ikar.app
object NfcExchange {
const val AID = "F04B4F4D455431"
private const val PREFIX = "KMT2:"
private val STATUS_OK = byteArrayOf(0x90.toByte(), 0x00)
private val STATUS_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte())
@Volatile var active: Boolean = false
@Volatile var selfId: Long = 0L
@Volatile var selfSession: String = ""
@Volatile var selfPhone: Long = 0L
@Volatile var onServed: (() -> Unit)? = null
data class Peer(val id: Long, val session: String, val phone: Long)
fun buildSelectResponse(): ByteArray {
val id = selfId
val session = selfSession
if (!active || id <= 0L || session.isEmpty()) return STATUS_NOT_FOUND
onServed?.invoke()
return (PREFIX + id + ":" + session + ":" + selfPhone)
.toByteArray(Charsets.UTF_8) + STATUS_OK
}
fun buildSelectCommand(): ByteArray {
val aid = hexToBytes(AID)
return byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) +
aid + byteArrayOf(0x00)
}
fun parsePeer(response: ByteArray?): Peer? {
if (response == null || response.size < 2) return null
val sw1 = response[response.size - 2]
val sw2 = response[response.size - 1]
if (sw1 != 0x90.toByte() || sw2.toInt() != 0x00) return null
val text = String(response.copyOfRange(0, response.size - 2), Charsets.UTF_8)
if (!text.startsWith(PREFIX)) return null
val parts = text.substring(PREFIX.length).split(":")
if (parts.size < 2) return null
val id = parts[0].toLongOrNull() ?: return null
val session = parts[1]
if (session.isEmpty()) return null
val phone = parts.getOrNull(2)?.toLongOrNull() ?: 0L
return Peer(id, session, phone)
}
private fun hexToBytes(hex: String): ByteArray {
val out = ByteArray(hex.length / 2)
for (i in out.indices) {
out[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return out
}
}
@@ -0,0 +1,25 @@
package ru.ikar.app
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
class NfcHostApduService : HostApduService() {
private val selectHeader = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00)
private val statusNotFound = byteArrayOf(0x6A, 0x82.toByte())
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
if (commandApdu == null || !isSelectApdu(commandApdu)) return statusNotFound
return NfcExchange.buildSelectResponse()
}
override fun onDeactivated(reason: Int) {}
private fun isSelectApdu(apdu: ByteArray): Boolean {
if (apdu.size < selectHeader.size) return false
for (i in selectHeader.indices) {
if (apdu[i] != selectHeader[i]) return false
}
return true
}
}
@@ -0,0 +1,109 @@
package ru.ikar.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Shader
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.abs
import kotlin.math.min
object NotifAvatars {
private val PALETTE = intArrayOf(
0xFF5B8DEF.toInt(), 0xFFEF5B8D.toInt(), 0xFF3FB950.toInt(),
0xFFE3883A.toInt(), 0xFF9B72F0.toInt(), 0xFF2AA9B5.toInt(),
)
fun load(ctx: Context, senderId: String, name: String): Bitmap {
val url = avatarUrl(ctx, senderId)
val raw = if (url != null) downloadBitmap(url) else null
return if (raw != null) circleCrop(raw) else initialsBitmap(name)
}
fun photo(ctx: Context, senderId: String): Bitmap? {
val url = avatarUrl(ctx, senderId) ?: return null
return downloadBitmap(url)?.let { circleCrop(it) }
}
fun initials(name: String): Bitmap = initialsBitmap(name)
private fun avatarUrl(ctx: Context, senderId: String): String? {
if (senderId.isEmpty()) return null
return try {
val prefs =
ctx.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
val raw = prefs.getString("flutter.contact_cache_v1", null) ?: return null
val entry = JSONObject(raw).optJSONObject(senderId) ?: return null
entry.optString("a", "").ifEmpty { null }
} catch (e: Exception) {
null
}
}
private fun downloadBitmap(url: String): Bitmap? {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 4000
conn.readTimeout = 5000
conn.doInput = true
conn.connect()
val bmp = BitmapFactory.decodeStream(conn.inputStream)
conn.disconnect()
bmp
} catch (e: Exception) {
null
}
}
private fun circleCrop(src: Bitmap): Bitmap {
val size = min(src.width, src.height)
val squared = Bitmap.createBitmap(
src, (src.width - size) / 2, (src.height - size) / 2, size, size,
)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val paint = Paint().apply {
isAntiAlias = true
shader = BitmapShader(squared, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
val r = size / 2f
Canvas(output).drawCircle(r, r, r, paint)
return output
}
private fun initialsBitmap(name: String): Bitmap {
val size = 256
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val bg = Paint().apply {
isAntiAlias = true
color = PALETTE[if (name.isEmpty()) 0 else abs(name.hashCode()) % PALETTE.size]
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, bg)
val tp = Paint().apply {
isAntiAlias = true
color = Color.WHITE
textAlign = Paint.Align.CENTER
textSize = size * 0.42f
isFakeBoldText = true
}
val fm = tp.fontMetrics
canvas.drawText(initialsOf(name), size / 2f, size / 2f - (fm.ascent + fm.descent) / 2, tp)
return output
}
private fun initialsOf(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
return when {
parts.isEmpty() -> "?"
parts.size == 1 -> parts[0].substring(0, 1).uppercase()
else -> (parts[0].substring(0, 1) + parts[1].substring(0, 1)).uppercase()
}
}
}
@@ -0,0 +1,21 @@
package ru.ikar.app
import android.app.Application
import com.ryanheise.audioservice.AudioServicePlugin
// id движка audio_service обязан быть выставлен до создания первого FlutterEngine.
// Создаёт его не только активити: AudioService поднимает движок сам, когда система
// стартует сервис по кнопке на гарнитуре. Выставленный позже id даёт второй движок —
// второй изолят со своим соединением и своей сессией БД.
class QlyraApplication : Application() {
override fun onCreate() {
super.onCreate()
AudioServicePlugin.setFlutterEngineId(ENGINE_ID)
LauncherBadge.clearRemovedCounter(this)
}
companion object {
const val ENGINE_ID = "qlyra_keep_engine"
}
}
@@ -0,0 +1,525 @@
package ru.ikar.app
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.json.JSONArray
import org.json.JSONObject
class QlyraFcmService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
Log.d("QlyraFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}")
if (data.isEmpty()) return
val type = data["type"]
FkmState.restore(applicationContext)
if (FkmState.enabled && FkmState.connected && type != "InboundCall" && type != "CallFinished") {
Log.d("QlyraFcm", "message push dropped: FKM handles messages")
return
}
QlyraNotifier(applicationContext).handle(data)
}
}
class QlyraNotifier(private val ctx: Context) {
companion object {
private const val CHANNEL_ID = "qlyra_messages"
private const val CHANNEL_NAME = "Сообщения"
private const val GROUP_KEY = "qlyra_messages_group"
private const val SUMMARY_ID = 424200
private const val PREFS = "qlyra_push"
private const val FLN_RECEIVER =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
private const val FLN_ACTION_TAPPED =
"com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver.ACTION_TAPPED"
private const val FLN_INPUT_RESULT = "FlutterLocalNotificationsPluginInputResult"
private const val HISTORY_LIMIT = 8
private const val ACCENT = 0xFF7C6BF0.toInt()
}
private data class Hist(
val text: String,
val key: String,
val name: String,
val ts: Long,
val mid: String,
val deleted: Boolean,
)
// Что нужно для перерисовки чата, когда нового пуша нет: правка и удаление
// приходят без заголовка, аккаунта и признака группы.
private data class ChatMeta(val title: String, val account: Int, val group: Boolean)
fun handle(data: Map<String, String>) {
when (data["type"]) {
"InboundCall" -> CallNotifier.showIncoming(ctx, data)
"CallFinished" -> CallNotifier.finishCall(ctx, data)
else -> showMessage(data)
}
}
private fun manager(): NotificationManager =
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (manager().getNotificationChannel(CHANNEL_ID) == null) {
manager().createNotificationChannel(
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH),
)
}
}
private fun notifIdOf(chatId: Long): Int = (chatId and 0x7fffffff).toInt()
private fun showMessage(data: Map<String, String>) {
val chatId = data["mc"]?.toLongOrNull() ?: return
val notifId = notifIdOf(chatId)
if (ChatNotifications.isDisplayed(chatId)) {
manager().cancel(notifId)
clearChat(chatId)
syncSummary(notifId, null)
return
}
val senderId = data["suid"] ?: ""
val senderName = data["userName"] ?: data["title"] ?: "ИКАР"
val chatTitle = data["title"] ?: senderName
val text = data["msg"] ?: data["body"] ?: data["text"] ?: "Новое сообщение"
val ts = data["ctime"]?.toLongOrNull() ?: data["ttime"]?.toLongOrNull()
?: System.currentTimeMillis()
ensureChannel()
val active = activeIds()
if (!active.contains(notifId)) clearChat(chatId)
val meta = ChatMeta(chatTitle, data["c"]?.toIntOrNull() ?: 0, chatTitle != senderName)
saveMeta(chatId, meta)
val history = appendHistory(
chatId,
Hist(text, senderId, senderName, ts, data["msgid"] ?: "", false),
)
render(chatId, notifId, meta, history, alertOnce = false)
updateSummary(notifId, senderName, text, ts, active)
}
fun cancelChat(chatId: Long) {
val notifId = notifIdOf(chatId)
manager().cancel(notifId)
clearChat(chatId)
syncSummary(notifId, null)
}
fun notifiedChats(): List<Long> {
val active = activeIds()
return pushPrefs().all.keys
.filter { it.startsWith("meta_") }
.mapNotNull { it.removePrefix("meta_").toLongOrNull() }
.filter { active.contains(notifIdOf(it)) }
}
// Сообщение отредактировали — правим текст в уже висящем уведомлении.
fun editMessage(data: Map<String, String>) {
val chatId = data["mc"]?.toLongOrNull() ?: return
val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return
val text = data["msg"]?.takeIf { it.isNotEmpty() } ?: return
val history = loadHistory(chatId)
val index = history.indexOfLast { it.mid == mid }
if (index < 0) return
val old = history[index]
if (old.deleted || old.text == text) return
val updated = history.toMutableList()
updated[index] = old.copy(text = text)
saveHistory(chatId, updated)
val notifId = notifIdOf(chatId)
if (!activeIds().contains(notifId)) return
val meta = loadMeta(chatId) ?: return
render(chatId, notifId, meta, updated, alertOnce = true)
syncSummary(notifId, updated.last())
}
// Сообщение удалили. keep — включено «показывать удалённые сообщения»:
// тогда строка остаётся в шторке, но зачёркнутой и с пометкой.
fun removeMessage(data: Map<String, String>) {
val chatId = data["mc"]?.toLongOrNull() ?: return
val mid = data["msgid"]?.takeIf { it.isNotEmpty() } ?: return
val keep = data["keep"] == "true"
val history = loadHistory(chatId)
val index = history.indexOfLast { it.mid == mid }
if (index < 0) return
if (keep && history[index].deleted) return
val updated = history.toMutableList()
if (keep) {
updated[index] = updated[index].copy(deleted = true)
} else {
updated.removeAt(index)
}
val notifId = notifIdOf(chatId)
if (updated.isEmpty()) {
clearChat(chatId)
manager().cancel(notifId)
syncSummary(notifId, null)
return
}
saveHistory(chatId, updated)
if (!activeIds().contains(notifId)) return
val meta = loadMeta(chatId) ?: return
render(chatId, notifId, meta, updated, alertOnce = true)
syncSummary(notifId, updated.last())
}
private fun render(
chatId: Long,
notifId: Int,
meta: ChatMeta,
history: List<Hist>,
alertOnce: Boolean,
) {
if (history.isEmpty()) return
ensureChannel()
val newest = history.last()
val avatarCache = HashMap<String, Bitmap>()
val personCache = HashMap<String, Person>()
fun avatarFor(key: String, name: String): Bitmap =
avatarCache.getOrPut(key) { NotifAvatars.load(ctx, key, name) }
fun personFor(key: String, name: String): Person =
personCache.getOrPut(key) {
Person.Builder()
.setName(name)
.setKey(key)
.setIcon(IconCompat.createWithBitmap(avatarFor(key, name)))
.build()
}
val shortcutId = "chat_$chatId"
publishShortcut(shortcutId, chatId, meta.title, personFor(newest.key, newest.name))
val style = NotificationCompat.MessagingStyle(Person.Builder().setName("Вы").build())
if (meta.group) {
style.conversationTitle = meta.title
style.isGroupConversation = true
}
for (h in history) {
style.addMessage(displayText(h), h.ts, personFor(h.key, h.name))
}
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ACCENT)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setGroup(GROUP_KEY)
.setWhen(newest.ts)
.setShowWhen(true)
.setOnlyAlertOnce(alertOnce)
.setContentIntent(openIntent(notifId, chatId))
.setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId))
.setStyle(style)
.setLargeIcon(avatarFor(newest.key, newest.name))
if (meta.account != 0) {
val replyTo = history.lastOrNull { !it.deleted }?.mid?.toLongOrNull()
builder.addAction(replyAction(notifId, meta.account, chatId, replyTo))
}
manager().notify(notifId, builder.build())
}
private fun displayText(h: Hist): CharSequence {
if (!h.deleted) return h.text
val sb = SpannableStringBuilder(ctx.getString(R.string.notif_deleted_prefix))
sb.append(' ')
val start = sb.length
sb.append(h.text)
sb.setSpan(StrikethroughSpan(), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return sb
}
private fun plainText(h: Hist): String =
if (h.deleted) "${ctx.getString(R.string.notif_deleted_prefix)} ${h.text}" else h.text
private fun updateSummary(
notifId: Int,
senderName: String,
text: String,
ts: Long,
activeBefore: Set<Int>,
) {
val reg = loadRegistry()
val kept = JSONObject()
val keys = reg.keys()
while (keys.hasNext()) {
val k = keys.next()
val id = k.toIntOrNull() ?: continue
if (id == notifId) continue
val entry = reg.optJSONObject(k) ?: continue
if (activeBefore.contains(id)) kept.put(k, entry)
}
kept.put(
notifId.toString(),
JSONObject().put("n", senderName).put("t", text).put("ts", ts),
)
saveRegistry(kept)
publishSummary(entriesOf(kept))
}
// `newest` == null — уведомление чата ушло из шторки.
private fun syncSummary(notifId: Int, newest: Hist?) {
val active = activeIds()
val reg = loadRegistry()
val kept = JSONObject()
val keys = reg.keys()
while (keys.hasNext()) {
val k = keys.next()
val id = k.toIntOrNull() ?: continue
if (id == notifId) continue
val entry = reg.optJSONObject(k) ?: continue
if (active.contains(id)) kept.put(k, entry)
}
if (newest != null && active.contains(notifId)) {
kept.put(
notifId.toString(),
JSONObject()
.put("n", newest.name)
.put("t", plainText(newest))
.put("ts", newest.ts),
)
}
saveRegistry(kept)
publishSummary(entriesOf(kept))
}
private fun entriesOf(reg: JSONObject): List<Triple<String, String, Long>> {
val entries = ArrayList<Triple<String, String, Long>>()
val keys = reg.keys()
while (keys.hasNext()) {
val o = reg.optJSONObject(keys.next()) ?: continue
entries.add(Triple(o.optString("n"), o.optString("t"), o.optLong("ts")))
}
entries.sortByDescending { it.third }
return entries
}
private fun publishSummary(entries: List<Triple<String, String, Long>>) {
if (entries.size < 2) {
manager().cancel(SUMMARY_ID)
return
}
val newest = entries.first()
val inbox = NotificationCompat.InboxStyle()
for (e in entries.take(6)) inbox.addLine(boldLine(e.first, e.second))
val summary = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(ACCENT)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setWhen(newest.third)
.setShowWhen(true)
.setNumber(entries.size)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setContentTitle("ИКАР")
.setContentText(boldLine(newest.first, newest.second))
.setStyle(inbox)
.build()
manager().notify(SUMMARY_ID, summary)
}
private fun boldLine(name: String, text: String): CharSequence {
val sb = SpannableStringBuilder()
sb.append(name)
sb.setSpan(StyleSpan(Typeface.BOLD), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sb.append(": ")
sb.append(text)
return sb
}
private fun replyAction(
notifId: Int,
account: Int,
chatId: Long,
replyTo: Long?,
): NotificationCompat.Action {
val payload = JSONObject()
.put("c", account)
.put("chat", chatId)
.apply { if (replyTo != null) put("mid", replyTo) }
.toString()
val intent = Intent(FLN_ACTION_TAPPED).apply {
setClassName(ctx, FLN_RECEIVER)
putExtra("notificationId", notifId)
putExtra("actionId", "reply")
putExtra("payload", payload)
putExtra("cancelNotification", false)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags = flags or PendingIntent.FLAG_MUTABLE
}
val pi = PendingIntent.getBroadcast(ctx, notifId, intent, flags)
val remoteInput = RemoteInput.Builder(FLN_INPUT_RESULT)
.setLabel("Сообщение…")
.build()
return NotificationCompat.Action.Builder(R.drawable.ic_notification, "Ответить", pi)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setAllowGeneratedReplies(true)
.build()
}
private fun publishShortcut(id: String, chatId: Long, title: String, person: Person) {
try {
val intent = chatIntent(chatId).setAction(Intent.ACTION_VIEW)
val shortcut = ShortcutInfoCompat.Builder(ctx, id)
.setShortLabel(title)
.setLongLived(true)
.setIntent(intent)
.setPerson(person)
.setIcon(person.icon)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setLocusId(LocusIdCompat(id))
.build()
ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut)
} catch (e: Exception) {
Log.w("QlyraFcm", "shortcut push failed: $e")
}
}
private fun chatIntent(chatId: Long): Intent {
val intent = LaunchIntents.app(ctx)
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP,
)
intent.putExtra(ChatNotifications.EXTRA_CHAT, chatId)
return intent
}
private fun openIntent(notifId: Int, chatId: Long): PendingIntent {
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(ctx, notifId, chatIntent(chatId), flags)
}
private fun activeIds(): Set<Int> = try {
manager().activeNotifications.map { it.id }.toSet()
} catch (e: Exception) {
emptySet()
}
private fun pushPrefs() = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun loadHistory(chatId: Long): List<Hist> {
val arr = try {
JSONArray(pushPrefs().getString("hist_$chatId", "[]"))
} catch (e: Exception) {
JSONArray()
}
val out = ArrayList<Hist>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
out.add(
Hist(
o.optString("t"),
o.optString("k"),
o.optString("n"),
o.optLong("ts"),
o.optString("m"),
o.optBoolean("d"),
),
)
}
return out
}
private fun saveHistory(chatId: Long, items: List<Hist>) {
val arr = JSONArray()
for (h in items) {
arr.put(
JSONObject()
.put("t", h.text)
.put("k", h.key)
.put("n", h.name)
.put("ts", h.ts)
.put("m", h.mid)
.put("d", h.deleted),
)
}
pushPrefs().edit().putString("hist_$chatId", arr.toString()).apply()
}
private fun appendHistory(chatId: Long, item: Hist): List<Hist> {
val items = ArrayList(loadHistory(chatId))
items.add(item)
while (items.size > HISTORY_LIMIT) items.removeAt(0)
saveHistory(chatId, items)
return items
}
private fun clearChat(chatId: Long) {
pushPrefs().edit().remove("hist_$chatId").remove("meta_$chatId").apply()
}
private fun saveMeta(chatId: Long, meta: ChatMeta) {
val json = JSONObject()
.put("t", meta.title)
.put("a", meta.account)
.put("g", meta.group)
.toString()
pushPrefs().edit().putString("meta_$chatId", json).apply()
}
private fun loadMeta(chatId: Long): ChatMeta? {
val raw = pushPrefs().getString("meta_$chatId", null) ?: return null
return try {
val o = JSONObject(raw)
ChatMeta(o.optString("t"), o.optInt("a"), o.optBoolean("g"))
} catch (e: Exception) {
null
}
}
private fun loadRegistry(): JSONObject = try {
JSONObject(pushPrefs().getString("registry", "{}") ?: "{}")
} catch (e: Exception) {
JSONObject()
}
private fun saveRegistry(reg: JSONObject) {
pushPrefs().edit().putString("registry", reg.toString()).apply()
}
}
@@ -0,0 +1,157 @@
package ru.ikar.app
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.plugin.common.EventChannel
import java.io.File
import java.util.concurrent.atomic.AtomicLong
object ShareIntake {
private const val TAG = "ShareIntake"
private const val CACHE_DIR = "shared_in"
private const val MAX_FILES = 30
private val seq = AtomicLong(0L)
@Volatile
var sink: EventChannel.EventSink? = null
fun isShare(intent: Intent?): Boolean {
val action = intent?.action ?: return false
return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
}
class Snapshot(
val uris: List<Uri>,
val text: String?,
val subject: String?,
val intentType: String?,
)
fun snapshot(intent: Intent): Snapshot? {
val uris = collectUris(intent).take(MAX_FILES)
val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (uris.isEmpty() && text.isNullOrBlank()) return null
return Snapshot(uris, text, subject, intent.type)
}
fun materialize(context: Context, snapshot: Snapshot): Map<String, Any?>? {
val files = ArrayList<Map<String, Any?>>()
for (uri in snapshot.uris) {
val copied = copyToCache(context, uri, snapshot.intentType)
if (copied != null) files.add(copied)
}
if (files.isEmpty() && snapshot.text.isNullOrBlank()) return null
return mapOf(
"files" to files,
"text" to snapshot.text,
"subject" to snapshot.subject,
)
}
fun clearCache(context: Context, paths: List<String>) {
try {
val dir = File(context.cacheDir, CACHE_DIR).canonicalFile
if (!dir.isDirectory) return
for (path in paths) {
val file = File(path).canonicalFile
if (file.parentFile == dir) file.delete()
}
} catch (e: Exception) {
Log.w(TAG, "cache cleanup failed: $e")
}
}
private fun collectUris(intent: Intent): List<Uri> {
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
val list = if (android.os.Build.VERSION.SDK_INT >= 33) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}
return list?.filterNotNull() ?: emptyList()
}
val single = if (android.os.Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
}
return if (single != null) listOf(single) else emptyList()
}
private fun copyToCache(context: Context, uri: Uri, intentType: String?): Map<String, Any?>? {
val resolver = context.contentResolver
val mime = resolveMime(resolver, uri, intentType)
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
return try {
val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
resolver.openInputStream(uri).use { input ->
if (input == null) return null
target.outputStream().use { output -> input.copyTo(output) }
}
if (target.length() <= 0L) {
target.delete()
return null
}
mapOf(
"path" to target.absolutePath,
"name" to displayName,
"mime" to mime,
"size" to target.length(),
)
} catch (e: Exception) {
Log.w(TAG, "cannot read $uri: $e")
null
}
}
private fun resolveMime(resolver: ContentResolver, uri: Uri, intentType: String?): String {
val fromResolver = resolver.getType(uri)
if (!fromResolver.isNullOrBlank() && fromResolver != "*/*") return fromResolver
val ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString())
if (!ext.isNullOrBlank()) {
val guessed = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.lowercase())
if (!guessed.isNullOrBlank()) return guessed
}
if (!intentType.isNullOrBlank() && intentType != "*/*") return intentType
return "application/octet-stream"
}
private fun queryDisplayName(resolver: ContentResolver, uri: Uri): String? {
if (uri.scheme == ContentResolver.SCHEME_FILE) return uri.lastPathSegment
return try {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
}
} catch (e: Exception) {
Log.w(TAG, "display name for $uri: $e")
null
}
}
private fun fallbackName(uri: Uri, mime: String): String {
val last = uri.lastPathSegment?.substringAfterLast('/')
if (!last.isNullOrBlank() && last.contains('.')) return last
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
return "shared_${System.currentTimeMillis()}.$ext"
}
private fun sanitize(name: String): String {
val cleaned = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
return if (cleaned.length <= 64) cleaned else cleaned.takeLast(64)
}
}
@@ -0,0 +1,128 @@
package ru.ikar.app
import android.app.*
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
class UploadForegroundService : Service() {
companion object {
const val CHANNEL_ID = "qlyra_upload"
const val NOTIFICATION_ID = 9001
const val ACTION_START = "ru.ikar.app.UPLOAD_START"
const val ACTION_UPDATE = "ru.ikar.app.UPLOAD_UPDATE"
const val ACTION_STOP = "ru.ikar.app.UPLOAD_STOP"
const val EXTRA_TITLE = "title"
const val EXTRA_BODY = "body"
const val EXTRA_PROGRESS = "progress"
const val EXTRA_INDETERMINATE = "indeterminate"
}
private var inForeground = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START, ACTION_UPDATE -> {
val notification = buildNotification(
title = intent.getStringExtra(EXTRA_TITLE)
?: applicationInfo.loadLabel(packageManager).toString(),
body = intent.getStringExtra(EXTRA_BODY) ?: "",
progress = intent.getIntExtra(EXTRA_PROGRESS, 0),
indeterminate = intent.getBooleanExtra(EXTRA_INDETERMINATE, true),
)
if (inForeground) {
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIFICATION_ID, notification)
} else {
goForeground(notification)
}
}
ACTION_STOP -> stopEverything()
}
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
stopEverything()
super.onTaskRemoved(rootIntent)
}
private fun goForeground(notification: Notification) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
inForeground = true
} catch (e: Exception) {
Log.w("UploadService", "startForeground failed: ${e.message}")
}
}
private fun stopEverything() {
if (inForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
inForeground = false
}
stopSelf()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
setShowBadge(false)
setSound(null, null)
enableVibration(false)
enableLights(false)
}
manager.createNotificationChannel(channel)
}
private fun buildNotification(
title: String,
body: String,
progress: Int,
indeterminate: Boolean,
): Notification {
val open = PendingIntent.getActivity(
this,
0,
LaunchIntents.app(this)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(open)
.setProgress(100, progress, indeterminate)
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setDefaults(0)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
}
return builder.build()
}
}
@@ -0,0 +1,352 @@
package ru.ikar.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.media3.common.Effect
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.BitmapOverlay
import androidx.media3.effect.Crop
import androidx.media3.effect.OverlayEffect
import androidx.media3.effect.Presentation
import androidx.media3.effect.RgbMatrix
import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.media3.effect.TextureOverlay
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import java.io.File
@UnstableApi
object VideoEditor {
private const val LOG_TAG = "VideoEditor"
private val main = Handler(Looper.getMainLooper())
private val progressHolder = ProgressHolder()
private var transformer: Transformer? = null
private var overlayBitmap: Bitmap? = null
private var pending: MethodChannel.Result? = null
private class ColorMatrixEffect(private val values: FloatArray) : RgbMatrix {
override fun getMatrix(presentationTimeUs: Long, useHdr: Boolean) = values
}
fun probe(input: String, result: MethodChannel.Result) {
Thread {
val data = try {
readInfo(input)
} catch (e: Exception) {
Log.w(LOG_TAG, "probe failed: ${e.message}")
null
}
main.post { result.success(data) }
}.start()
}
private fun readInfo(input: String): Map<String, Any?>? {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(input)
val rotation = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0
val rawWidth = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull() ?: 0
val rawHeight = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull() ?: 0
val duration = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
if (rawWidth <= 0 || rawHeight <= 0) return null
val swap = rotation % 180 != 0
val track = readTrackInfo(input)
return mapOf(
"width" to if (swap) rawHeight else rawWidth,
"height" to if (swap) rawWidth else rawHeight,
"durationMs" to duration,
"fps" to track.first,
"hasAudio" to track.second,
)
} finally {
retriever.release()
}
}
private fun readTrackInfo(input: String): Pair<Double, Boolean> {
val extractor = MediaExtractor()
var fps = 30.0
var hasAudio = false
try {
extractor.setDataSource(input)
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) hasAudio = true
if (mime.startsWith("video/") &&
format.containsKey(MediaFormat.KEY_FRAME_RATE)
) {
fps = try {
format.getInteger(MediaFormat.KEY_FRAME_RATE).toDouble()
} catch (_: ClassCastException) {
format.getFloat(MediaFormat.KEY_FRAME_RATE).toDouble()
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "track info failed: ${e.message}")
} finally {
extractor.release()
}
return Pair(if (fps > 0) fps else 30.0, hasAudio)
}
fun frames(
input: String,
times: List<Int>,
size: Int,
precise: Boolean,
result: MethodChannel.Result,
) {
Thread {
val out = ArrayList<ByteArray?>(times.size)
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(input)
for (ms in times) {
out.add(grabFrame(retriever, ms.toLong(), size, precise))
}
} catch (e: Exception) {
Log.w(LOG_TAG, "frames failed: ${e.message}")
while (out.size < times.size) out.add(null)
} finally {
retriever.release()
}
main.post { result.success(out) }
}.start()
}
private fun grabFrame(
retriever: MediaMetadataRetriever,
timeMs: Long,
size: Int,
precise: Boolean,
): ByteArray? {
val us = timeMs * 1000L
val option = if (precise) {
MediaMetadataRetriever.OPTION_CLOSEST
} else {
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
}
val bitmap = (
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
retriever.getScaledFrameAtTime(us, option, size, size)
} else {
retriever.getFrameAtTime(us, option)
}
} catch (e: Exception) {
Log.w(LOG_TAG, "frame at $timeMs failed: ${e.message}")
null
}
) ?: return null
return try {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 82, stream)
stream.toByteArray()
} finally {
bitmap.recycle()
}
}
fun edit(context: Context, call: MethodCall, result: MethodChannel.Result) {
val input = call.argument<String>("input")
val output = call.argument<String>("output")
if (input == null || output == null) {
result.error("BAD_ARGS", "input/output required", null)
return
}
release()
pending = result
try {
val effects = buildEffects(call)
val item = MediaItem.Builder()
.setUri(Uri.fromFile(File(input)))
.setClippingConfiguration(buildClipping(call))
.build()
val edited = EditedMediaItem.Builder(item)
.setRemoveAudio(call.argument<Boolean>("removeAudio") == true)
.setEffects(Effects(emptyList(), effects))
.build()
val builder = Transformer.Builder(context)
.addListener(object : Transformer.Listener {
override fun onCompleted(
composition: Composition,
exportResult: ExportResult,
) = finish(true)
override fun onError(
composition: Composition,
exportResult: ExportResult,
exportException: ExportException,
) {
Log.w(LOG_TAG, "export failed: ${exportException.message}")
finish(false)
}
})
val bitrate = call.argument<Int>("bitrate")
if (bitrate != null && bitrate > 0) {
builder.setEncoderFactory(
DefaultEncoderFactory.Builder(context)
.setRequestedVideoEncoderSettings(
VideoEncoderSettings.Builder()
.setBitrate(bitrate)
.setBitrateMode(
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR,
)
.build(),
)
.build(),
)
}
val transformer = builder.build()
this.transformer = transformer
transformer.start(edited, output)
} catch (e: Exception) {
Log.w(LOG_TAG, "export start failed: ${e.message}")
finish(false)
}
}
private fun finish(ok: Boolean) {
val result = pending
pending = null
release()
result?.success(ok)
}
private fun buildClipping(call: MethodCall): MediaItem.ClippingConfiguration {
val builder = MediaItem.ClippingConfiguration.Builder()
val start = call.argument<Number>("startMs")?.toLong()
val end = call.argument<Number>("endMs")?.toLong()
if (start != null && start > 0) builder.setStartPositionMs(start)
if (end != null && end > 0) builder.setEndPositionMs(end)
return builder.build()
}
private fun buildEffects(call: MethodCall): List<Effect> {
val effects = mutableListOf<Effect>()
val rotation = call.argument<Number>("rotationDegrees")?.toFloat() ?: 0f
val flipH = call.argument<Boolean>("flipH") == true
if (flipH || kotlin.math.abs(rotation) > 0.01f) {
effects.add(
ScaleAndRotateTransformation.Builder()
.setScale(if (flipH) -1f else 1f, 1f)
.setRotationDegrees(rotation)
.build(),
)
}
val crop = call.argument<List<Double>>("crop")
if (crop != null && crop.size == 4) {
effects.add(
Crop(
crop[0].toFloat(),
crop[1].toFloat(),
crop[2].toFloat(),
crop[3].toFloat(),
),
)
}
val width = call.argument<Int>("outWidth") ?: 0
val height = call.argument<Int>("outHeight") ?: 0
if (width > 0 && height > 0) {
effects.add(
Presentation.createForWidthAndHeight(
width,
height,
Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP,
),
)
}
val matrix = call.argument<List<Double>>("rgbMatrix")
if (matrix != null && matrix.size == 16) {
effects.add(
ColorMatrixEffect(FloatArray(16) { matrix[it].toFloat() }),
)
}
val overlay = call.argument<String>("overlay")
if (overlay != null) {
val bitmap = BitmapFactory.decodeFile(overlay)
if (bitmap != null) {
overlayBitmap = bitmap
effects.add(
OverlayEffect(
listOf<TextureOverlay>(
BitmapOverlay.createStaticBitmapOverlay(bitmap),
),
),
)
}
}
return effects
}
fun progress(result: MethodChannel.Result) {
val active = transformer
if (active == null) {
result.success(-1)
return
}
val state = try {
active.getProgress(progressHolder)
} catch (_: IllegalStateException) {
result.success(-1)
return
}
result.success(
if (state == Transformer.PROGRESS_STATE_AVAILABLE) {
progressHolder.progress
} else {
-1
},
)
}
fun cancel(result: MethodChannel.Result) {
try {
transformer?.cancel()
} catch (e: Exception) {
Log.w(LOG_TAG, "cancel failed: ${e.message}")
}
finish(false)
result.success(null)
}
private fun release() {
transformer = null
overlayBitmap?.recycle()
overlayBitmap = null
}
}
@@ -0,0 +1,864 @@
package ru.ikar.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.SurfaceTexture
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.params.StreamConfigurationMap
import android.media.MediaRecorder
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.util.Range
import android.util.Size
import android.view.Surface
import androidx.core.content.ContextCompat
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.TextureRegistry
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
// Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный
// кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит
// одновременно в превью (Flutter Texture) и в MediaRecorder (квадрат, H.264,
// framework MediaMuxer). Так делает официальный клиент через CameraX — выход
// проходит серверный валидатор (media3-перекод его НЕ проходит).
// По умолчанию 480×480@30 как у официального клиента; размер и fps
// настраиваются из дев-меню.
class VideoNoteRecorder(
private val context: Context,
private val textureRegistry: TextureRegistry,
requestedEdge: Int = 480,
requestedFps: Int = 30,
) {
private val tag = "VideoNoteRecorder"
private val edge = requestedEdge.coerceIn(240, 1080)
private val fps = requestedFps.coerceIn(24, 60)
// 1 Мбит/с — базовый битрейт официального клиента для 480×480@30;
// масштабируем по площади кадра и частоте.
private val bitrate =
(1_024_000L * edge * edge / (480L * 480L) * fps / 30L).toInt()
private var cameraId = ""
private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT
private var sensorOrientation = 270
private var camSize = Size(1280, 720)
private var fpsRange: Range<Int>? = null
private var hasOis = false
private var hasEis = false
private var hasFlash = false
private var torchOn = false
private var previewRequest: CaptureRequest.Builder? = null
private var cameraDevice: CameraDevice? = null
private var session: CameraCaptureSession? = null
private var recorder: MediaRecorder? = null
private var outputPath: String? = null
private var camThread: HandlerThread? = null
private var camHandler: Handler? = null
private var glThread: HandlerThread? = null
private var glHandler: Handler? = null
private var flutterEntry: TextureRegistry.SurfaceTextureEntry? = null
private var previewSurface: Surface? = null
private var recorderSurface: Surface? = null
// GL state (живёт на glThread)
private var egl: EglCore? = null
private var previewWindow: WindowSurface? = null
private var recordWindow: WindowSurface? = null
private var oesTexId = 0
private var camTexture: SurfaceTexture? = null
private var camInputSurface: Surface? = null
private var program: OesProgram? = null
private val stMatrix = FloatArray(16)
@Volatile private var recording = false
@Volatile private var glReady = false
private fun manager() =
context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private fun selectCamera(facing: Int): Boolean {
val mgr = manager()
for (id in mgr.cameraIdList) {
val ch = mgr.getCameraCharacteristics(id)
if (ch.get(CameraCharacteristics.LENS_FACING) == facing) {
applyCamera(id, ch)
return true
}
}
return false
}
private fun selectCameraById(id: String): Boolean {
val mgr = manager()
if (id !in mgr.cameraIdList) return false
applyCamera(id, mgr.getCameraCharacteristics(id))
return true
}
private fun applyCamera(id: String, ch: CameraCharacteristics) {
cameraId = id
lensFacing = ch.get(CameraCharacteristics.LENS_FACING)
?: CameraCharacteristics.LENS_FACING_BACK
sensorOrientation =
ch.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 270
val map = ch.get(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP,
)
camSize = pickCamSize(map)
fpsRange = pickFpsRange(
ch.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES),
)
hasOis = ch.get(
CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION,
)?.contains(
CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON,
) == true
hasEis = ch.get(
CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES,
)?.contains(
CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON,
) == true
hasFlash = ch.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
if (!hasFlash) torchOn = false
}
// Поддерживаемый камерой размер вывода (для SurfaceTexture): короткая
// сторона не меньше edge, целимся в ближайший 16:9 (720p для 480/720,
// 1080p для 1080).
private fun pickCamSize(map: StreamConfigurationMap?): Size {
val targetShort = maxOf(720, edge)
val targetLong = targetShort * 16 / 9
val fallback = Size(targetLong, targetShort)
val sizes = map?.getOutputSizes(SurfaceTexture::class.java)
?: return fallback
var best = sizes.firstOrNull() ?: fallback
var bestScore = Int.MAX_VALUE
for (s in sizes) {
val longSide = maxOf(s.width, s.height)
val shortSide = minOf(s.width, s.height)
if (shortSide < edge) continue
val score = kotlin.math.abs(longSide - targetLong) +
kotlin.math.abs(shortSide - targetShort)
if (score < bestScore) {
bestScore = score
best = s
}
}
return best
}
// Диапазон AE под запрошенный fps: предпочитаем фиксированный [fps, fps],
// иначе самый узкий диапазон, включающий fps; если 60 недоступно —
// максимально быстрый из имеющихся.
private fun pickFpsRange(ranges: Array<Range<Int>>?): Range<Int>? {
if (ranges == null || ranges.isEmpty()) return null
val covering = ranges.filter { it.lower <= fps && fps <= it.upper }
if (covering.isNotEmpty()) {
return covering.minByOrNull { (it.upper - it.lower) * 1000 + (fps - it.lower) }
}
return ranges.maxByOrNull { it.upper * 1000 - (it.upper - it.lower) }
}
fun init(
facingFront: Boolean,
preferredCameraId: String?,
rawResult: MethodChannel.Result,
) {
val result = OnceResult(rawResult)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) {
result.error("NO_CAMERA_PERMISSION", "camera permission required", null)
return
}
if (!hasMicPermission()) {
result.error("NO_MIC_PERMISSION", "microphone permission required", null)
return
}
try {
val facing = if (facingFront) {
CameraCharacteristics.LENS_FACING_FRONT
} else {
CameraCharacteristics.LENS_FACING_BACK
}
val preferred = preferredCameraId?.takeIf { it.isNotEmpty() }
if ((preferred == null || !selectCameraById(preferred)) &&
!selectCamera(facing) &&
!selectCamera(CameraCharacteristics.LENS_FACING_BACK)
) {
result.error("NO_CAMERA", "no camera found", null)
return
}
camThread = HandlerThread("VideoNoteCam").also { it.start() }
camHandler = Handler(camThread!!.looper)
glThread = HandlerThread("VideoNoteGL").also { it.start() }
glHandler = Handler(glThread!!.looper)
val entry = textureRegistry.createSurfaceTexture()
flutterEntry = entry
entry.surfaceTexture().setDefaultBufferSize(edge, edge)
previewSurface = Surface(entry.surfaceTexture())
glHandler!!.post {
try {
setupGl()
glReady = true
openCamera(result, entry.id())
} catch (e: Exception) {
Log.e(tag, "GL setup failed", e)
result.error("GL_FAILED", e.message, null)
}
}
} catch (e: Exception) {
Log.e(tag, "init failed", e)
result.error("INIT_FAILED", e.message, null)
}
}
// === GL (на glThread) ===
private fun setupGl() {
val core = EglCore()
egl = core
previewWindow = WindowSurface(core, previewSurface!!, core.displayConfig)
.also { it.makeCurrent() }
program = OesProgram()
oesTexId = program!!.createOesTexture()
val st = SurfaceTexture(oesTexId)
st.setDefaultBufferSize(camSize.width, camSize.height)
st.setOnFrameAvailableListener({ onFrame() }, glHandler)
camTexture = st
camInputSurface = Surface(st)
Log.i(tag, "GL setup ok cam=$camSize")
}
private var frameCount = 0
private fun onFrame() {
val st = camTexture ?: return
val prog = program ?: return
val w = previewWindow ?: return
try {
w.makeCurrent()
st.updateTexImage()
st.getTransformMatrix(stMatrix)
} catch (e: Exception) {
Log.w(tag, "onFrame update: ${e.message}")
return
}
run {
GLES20.glViewport(0, 0, edge, edge)
prog.draw(oesTexId, stMatrix, camSize, lensFacing, true)
w.swap()
}
if (frameCount == 0) {
Log.i(
tag,
"first frame cam=$camSize orient=$sensorOrientation " +
"facing=$lensFacing st=[${stMatrix.joinToString(",") { "%.2f".format(it) }}]",
)
}
frameCount++
if (recording) {
recordWindow?.let { w ->
w.makeCurrent()
GLES20.glViewport(0, 0, edge, edge)
prog.draw(oesTexId, stMatrix, camSize, lensFacing, true)
w.setPresentationTime(System.nanoTime())
w.swap()
}
}
}
@Suppress("MissingPermission")
private fun openCamera(result: MethodChannel.Result, textureId: Long) {
manager().openCamera(
cameraId,
object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
Log.i(tag, "camera opened $cameraId")
cameraDevice = device
startPreviewSession(result, textureId)
}
override fun onDisconnected(device: CameraDevice) {
device.close(); cameraDevice = null
}
override fun onError(device: CameraDevice, error: Int) {
device.close(); cameraDevice = null
result.error("CAMERA_ERROR", "code $error", null)
}
},
camHandler,
)
}
private fun startPreviewSession(result: MethodChannel.Result, textureId: Long) {
val device = cameraDevice ?: return
val camSurface = camInputSurface ?: return
try {
createSession(listOf(camSurface)) { s ->
session = s
val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
req.addTarget(camSurface)
fpsRange?.let {
req.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
}
// Оптическая стабилизация, если линза умеет; иначе электронная.
// Обе сразу включать нельзя — на многих устройствах конфликтуют.
if (hasOis) {
req.set(
CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE,
CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON,
)
} else if (hasEis) {
req.set(
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON,
)
}
previewRequest = req
applyTorch(req)
s.setRepeatingRequest(req.build(), null, camHandler)
Log.i(
tag,
"preview session configured fpsRange=$fpsRange " +
"ois=$hasOis eis=$hasEis flash=$hasFlash",
)
result.success(
mapOf(
"textureId" to textureId,
"size" to edge,
"hasFlash" to hasFlash,
),
)
}
} catch (e: Exception) {
Log.e(tag, "preview session failed", e)
result.error("PREVIEW_FAILED", e.message, null)
}
}
private fun setupRecorder(path: String) {
val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION") MediaRecorder()
}
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setVideoSource(MediaRecorder.VideoSource.SURFACE)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setVideoSize(edge, edge)
rec.setVideoEncodingBitRate(bitrate)
rec.setVideoFrameRate(fps)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(48000)
rec.setAudioEncodingBitRate(96000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
rec.setVideoEncodingProfileLevel(
android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh,
avcLevel(),
)
} catch (e: Exception) {
Log.w(tag, "profile level: ${e.message}")
}
}
rec.setOutputFile(path)
rec.prepare()
recorder = rec
recorderSurface = rec.surface
}
// Минимальный уровень AVC, вмещающий выбранные размер и fps
// (Level 3 — как у официального клиента для 480@30).
private fun avcLevel(): Int {
return when {
edge <= 480 && fps <= 30 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3
edge <= 720 && fps <= 30 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel31
edge <= 720 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel32
fps <= 30 ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel4
else ->
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel42
}
}
// Смена камеры на лету (в т.ч. во время записи): GL-конвейер и
// MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия —
// кадры новой камеры продолжают приходить в тот же SurfaceTexture.
private fun applyTorch(req: CaptureRequest.Builder) {
req.set(
CaptureRequest.FLASH_MODE,
if (torchOn && hasFlash) {
CaptureRequest.FLASH_MODE_TORCH
} else {
CaptureRequest.FLASH_MODE_OFF
},
)
}
fun setTorch(on: Boolean, result: MethodChannel.Result) {
if (!hasFlash) {
result.success(false); return
}
val s = session
val req = previewRequest
if (s == null || req == null) {
result.error("NOT_READY", "no preview session", null); return
}
torchOn = on
try {
applyTorch(req)
s.setRepeatingRequest(req.build(), null, camHandler)
result.success(torchOn)
} catch (e: Exception) {
Log.e(tag, "torch failed", e)
torchOn = false
result.error("TORCH_FAILED", e.message, null)
}
}
fun switchCamera(rawResult: MethodChannel.Result) {
val result = OnceResult(rawResult)
if (cameraDevice == null || !glReady) {
result.error("NOT_READY", "camera not initialized", null); return
}
val newFacing = if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) {
CameraCharacteristics.LENS_FACING_BACK
} else {
CameraCharacteristics.LENS_FACING_FRONT
}
try { session?.close() } catch (_: Exception) {}
session = null
previewRequest = null
try { cameraDevice?.close() } catch (_: Exception) {}
cameraDevice = null
if (!selectCamera(newFacing)) {
result.error("NO_CAMERA", "no camera for facing $newFacing", null)
return
}
val entry = flutterEntry
if (entry == null) {
result.error("NOT_READY", "texture released", null)
return
}
glHandler?.post {
camTexture?.setDefaultBufferSize(camSize.width, camSize.height)
}
Log.i(tag, "switching camera to $cameraId facing=$lensFacing cam=$camSize")
openCamera(result, entry.id())
}
private fun hasMicPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
fun start(result: MethodChannel.Result) {
if (cameraDevice == null || !glReady) {
result.error("NOT_READY", "camera not initialized", null); return
}
if (!hasMicPermission()) {
result.error("NO_MIC_PERMISSION", "microphone permission required", null)
return
}
try {
val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath
outputPath = path
setupRecorder(path)
val recSurface = recorderSurface!!
glHandler!!.post {
try {
recordWindow = WindowSurface(egl!!, recSurface, egl!!.recordConfig)
recorder?.start()
recording = true
result.success(null)
} catch (e: Exception) {
Log.e(tag, "record window failed", e)
result.error("START_FAILED", e.message, null)
}
}
} catch (e: Exception) {
Log.e(tag, "start failed", e)
result.error("START_FAILED", e.message, null)
}
}
fun stop(result: MethodChannel.Result) {
if (!recording) {
result.error("NOT_RECORDING", "no active recording", null); return
}
recording = false
glHandler!!.post {
try {
recordWindow?.release()
recordWindow = null
} catch (_: Exception) {}
try {
try {
recorder?.stop()
} catch (e: Exception) {
Log.w(tag, "recorder.stop: ${e.message}")
}
recorder?.reset(); recorder?.release(); recorder = null
recorderSurface = null
outputPath?.let { stripVideoEditList(it) }
result.success(outputPath)
} catch (e: Exception) {
Log.e(tag, "stop failed", e)
result.error("STOP_FAILED", e.message, null)
}
}
}
// MediaRecorder добавляет на видео-трек edit list (edts/elst) из-за
// B-кадров — серверный валидатор такие файлы отвергает (у клиента видео без
// edit list). Переименовываем бокс edts→free (тот же размер, ничего не
// сдвигается, парсеры пропускают free) — edit list исчезает без перекода.
private fun stripVideoEditList(path: String) {
try {
val f = java.io.RandomAccessFile(path, "rw")
val scan = minOf(f.length(), 65536L).toInt()
val buf = ByteArray(scan)
f.seek(0); f.readFully(buf)
var i = 0
while (i + 8 <= scan) {
if (buf[i] == 0x65.toByte() && buf[i + 1] == 0x64.toByte() &&
buf[i + 2] == 0x74.toByte() && buf[i + 3] == 0x73.toByte()
) {
f.seek(i.toLong())
f.write(byteArrayOf(0x66, 0x72, 0x65, 0x65))
Log.i(tag, "stripped video edit list at $i")
}
i++
}
f.close()
} catch (e: Exception) {
Log.w(tag, "stripEditList: ${e.message}")
}
}
fun dispose() {
recording = false
try { session?.close() } catch (_: Exception) {}
session = null
glHandler?.post {
try { recordWindow?.release() } catch (_: Exception) {}
try { recorder?.reset(); recorder?.release() } catch (_: Exception) {}
recorder = null
try { camTexture?.release() } catch (_: Exception) {}
try { camInputSurface?.release() } catch (_: Exception) {}
try { previewWindow?.release() } catch (_: Exception) {}
try { program?.release() } catch (_: Exception) {}
try { egl?.release() } catch (_: Exception) {}
}
cameraDevice?.close(); cameraDevice = null
previewSurface?.release(); previewSurface = null
flutterEntry?.release(); flutterEntry = null
glThread?.quitSafely(); glThread = null; glHandler = null
camThread?.quitSafely(); camThread = null; camHandler = null
}
@Suppress("DEPRECATION")
private fun createSession(
surfaces: List<Surface>,
onReady: (CameraCaptureSession) -> Unit,
) {
val device = cameraDevice ?: return
device.createCaptureSession(
surfaces,
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) = onReady(s)
override fun onConfigureFailed(s: CameraCaptureSession) {
Log.e(tag, "session config failed")
}
},
camHandler,
)
}
}
private class OnceResult(private val inner: MethodChannel.Result) : MethodChannel.Result {
private val done = java.util.concurrent.atomic.AtomicBoolean(false)
override fun success(result: Any?) {
if (done.compareAndSet(false, true)) inner.success(result)
}
override fun error(code: String, message: String?, details: Any?) {
if (done.compareAndSet(false, true)) inner.error(code, message, details)
}
override fun notImplemented() {
if (done.compareAndSet(false, true)) inner.notImplemented()
}
}
// ── Минимальный EGL/GL под рендер OES-текстуры в квадратные surface ──
private class EglCore {
val display: EGLDisplay
val displayConfig: EGLConfig // без recordable — семплируется Flutter-текстурой
val recordConfig: EGLConfig // с recordable — для MediaRecorder-surface
val eglContext: EGLContext
init {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
val ver = IntArray(2)
EGL14.eglInitialize(display, ver, 0, ver, 1)
// Два конфига: превью (Flutter Texture) НЕ должно иметь
// EGL_RECORDABLE_ANDROID — иначе буферы получают video-encoder usage
// и не семплируются как картинка (чёрное превью). А encoder-surface
// MediaRecorder, наоборот, требует recordable.
displayConfig = chooseConfig(false)
recordConfig = chooseConfig(true)
val ctxAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
eglContext = EGL14.eglCreateContext(
display, displayConfig, EGL14.EGL_NO_CONTEXT, ctxAttribs, 0,
)
}
private fun chooseConfig(recordable: Boolean): EGLConfig {
val attribs = if (recordable) {
intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
0x3142, 1, EGL14.EGL_NONE,
)
} else {
intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_NONE,
)
}
val configs = arrayOfNulls<EGLConfig>(1)
val num = IntArray(1)
EGL14.eglChooseConfig(display, attribs, 0, configs, 0, 1, num, 0)
return configs[0]!!
}
fun release() {
EGL14.eglMakeCurrent(
display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT,
)
EGL14.eglDestroyContext(display, eglContext)
EGL14.eglReleaseThread()
EGL14.eglTerminate(display)
}
}
private class WindowSurface(
private val core: EglCore,
surface: Surface,
config: EGLConfig,
) {
private var eglSurface: EGLSurface =
EGL14.eglCreateWindowSurface(
core.display, config, surface, intArrayOf(EGL14.EGL_NONE), 0,
).also {
if (it == EGL14.EGL_NO_SURFACE) {
Log.w("VideoNoteRecorder", "eglCreateWindowSurface FAILED err=${EGL14.eglGetError()}")
}
}
fun makeCurrent() {
EGL14.eglMakeCurrent(core.display, eglSurface, eglSurface, core.eglContext)
}
fun swap() {
EGL14.eglSwapBuffers(core.display, eglSurface)
}
fun setPresentationTime(ns: Long) {
EGLExt14.setPresentationTime(core.display, eglSurface, ns)
}
fun release() {
EGL14.eglDestroySurface(core.display, eglSurface)
}
}
private object EGLExt14 {
fun setPresentationTime(display: EGLDisplay, surface: EGLSurface, ns: Long) {
android.opengl.EGLExt.eglPresentationTimeANDROID(display, surface, ns)
}
}
// Рисует OES-текстуру камеры в текущий квадратный surface, кропая центральный
// квадрат и учитывая ориентацию сенсора + зеркало фронталки.
private class OesProgram {
private val vertexShader = """
attribute vec4 aPosition;
attribute vec4 aTexCoord;
uniform mat4 uTexMatrix;
varying vec2 vTex;
void main() {
gl_Position = aPosition;
vTex = (uTexMatrix * aTexCoord).xy;
}
"""
private val fragmentShader = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTex;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = vec4(texture2D(sTexture, vTex).rgb, 1.0);
}
"""
private val program: Int
private val aPosition: Int
private val aTexCoord: Int
private val uTexMatrix: Int
private val uTexture: Int
private val quad: FloatBuffer
private val tex: FloatBuffer
private val mirrorM = FloatArray(16)
private val cropM = FloatArray(16)
private val stMirrorM = FloatArray(16)
private val fullM = FloatArray(16)
init {
program = buildProgram(vertexShader, fragmentShader)
aPosition = GLES20.glGetAttribLocation(program, "aPosition")
aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord")
uTexMatrix = GLES20.glGetUniformLocation(program, "uTexMatrix")
uTexture = GLES20.glGetUniformLocation(program, "sTexture")
quad = floatBuf(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
tex = floatBuf(floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f))
}
fun createOesTexture(): Int {
val ids = IntArray(1)
GLES20.glGenTextures(1, ids, 0)
val id = ids[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, id)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE,
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE,
)
return id
}
fun draw(
texId: Int,
st: FloatArray,
camSize: Size,
lensFacing: Int,
mirror: Boolean,
) {
Matrix.setIdentityM(mirrorM, 0)
if (mirror && lensFacing == CameraCharacteristics.LENS_FACING_FRONT) {
Matrix.translateM(mirrorM, 0, 0.5f, 0.5f, 0f)
Matrix.scaleM(mirrorM, 0, -1f, 1f, 1f)
Matrix.translateM(mirrorM, 0, -0.5f, -0.5f, 0f)
}
val w = camSize.width.toFloat()
val h = camSize.height.toFloat()
Matrix.setIdentityM(cropM, 0)
Matrix.translateM(cropM, 0, 0.5f, 0.5f, 0f)
if (w >= h) {
Matrix.scaleM(cropM, 0, h / w, 1f, 1f)
} else {
Matrix.scaleM(cropM, 0, 1f, w / h, 1f)
}
Matrix.translateM(cropM, 0, -0.5f, -0.5f, 0f)
Matrix.multiplyMM(stMirrorM, 0, st, 0, mirrorM, 0)
Matrix.multiplyMM(fullM, 0, cropM, 0, stMirrorM, 0)
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId)
GLES20.glUniform1i(uTexture, 0)
GLES20.glUniformMatrix4fv(uTexMatrix, 1, false, fullM, 0)
GLES20.glEnableVertexAttribArray(aPosition)
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 0, quad)
GLES20.glEnableVertexAttribArray(aTexCoord)
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 0, tex)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(aPosition)
GLES20.glDisableVertexAttribArray(aTexCoord)
}
fun release() {
GLES20.glDeleteProgram(program)
}
private fun floatBuf(data: FloatArray): FloatBuffer {
val bb = ByteBuffer.allocateDirect(data.size * 4).order(ByteOrder.nativeOrder())
val fb = bb.asFloatBuffer()
fb.put(data).position(0)
return fb
}
private fun buildProgram(vs: String, fs: String): Int {
val v = compile(GLES20.GL_VERTEX_SHADER, vs)
val f = compile(GLES20.GL_FRAGMENT_SHADER, fs)
val p = GLES20.glCreateProgram()
GLES20.glAttachShader(p, v)
GLES20.glAttachShader(p, f)
GLES20.glLinkProgram(p)
val status = IntArray(1)
GLES20.glGetProgramiv(p, GLES20.GL_LINK_STATUS, status, 0)
if (status[0] == 0) {
val log = GLES20.glGetProgramInfoLog(p)
GLES20.glDeleteProgram(p)
throw RuntimeException("link failed: $log")
}
return p
}
private fun compile(type: Int, src: String): Int {
val s = GLES20.glCreateShader(type)
GLES20.glShaderSource(s, src)
GLES20.glCompileShader(s)
val status = IntArray(1)
GLES20.glGetShaderiv(s, GLES20.GL_COMPILE_STATUS, status, 0)
if (status[0] == 0) {
val log = GLES20.glGetShaderInfoLog(s)
GLES20.glDeleteShader(s)
throw RuntimeException("compile failed: $log")
}
return s
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"><item android:drawable="@color/launch_surface" /></layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4C22,2.9 21.1,2 20,2z" />
</vector>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"><item android:drawable="@color/launch_surface" /></layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_minimal_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_minimal_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="NormalTheme">
<item name="android:windowBackground">@color/launch_surface</item>
<item name="android:windowSplashScreenBackground">@color/launch_surface</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_surface">#141318</color>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="Theme.AppCompat.NoActionBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowDisablePreview">true</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@color/launch_surface</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<resources>
<string name="upload_channel_name">Отправка медиа</string>
<string name="fkm_channel_name">Сервис уведомлений</string>
<string name="fkm_channel_description">Держит фоновое соединение с сервером</string>
<string name="fkm_title">ИКАР · сервис уведомлений</string>
<string name="fkm_status_active">Соединение активно</string>
<string name="fkm_status_inactive">Соединение не активно</string>
<string name="fkm_status_line">%1$s · принято %2$d</string>
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
<string name="fkm_disable">Выключить</string>
<string name="notif_deleted_prefix">Удалено:</string>
<string name="share_target_label">Отправить в чат</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 owns the cold-start splash. Never inherit its white default. -->
<style name="LaunchTheme" parent="NormalTheme">
<item name="android:windowBackground">@color/launch_surface</item>
<item name="android:windowSplashScreenBackground">@color/launch_surface</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
<color name="ic_launcher_minimal_background">#000000</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="google_app_id">1:91097833269:android:c123132a32a690ba19b67c</string>
<string name="google_api_key">AIzaSyCgjHUi4zFNRfwAwsiLBlRZCGup78N74ks</string>
<string name="gcm_defaultSenderId">91097833269</string>
<string name="project_id">ikar-3bdfb</string>
<string name="google_storage_bucket">ikar-3bdfb.firebasestorage.app</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_surface">#F3F0FA</color>
</resources>
@@ -0,0 +1,15 @@
<resources>
<string name="nfc_service_description">ИКАР contact exchange</string>
<string name="nfc_aid_group_description">ИКАР contact exchange</string>
<string name="upload_channel_name">Sending media</string>
<string name="fkm_channel_name">Notification service</string>
<string name="fkm_channel_description">Keeps the background connection to the server alive</string>
<string name="fkm_title">ИКАР · notification service</string>
<string name="fkm_status_active">Connection active</string>
<string name="fkm_status_inactive">Connection inactive</string>
<string name="fkm_status_line">%1$s · %2$d delivered</string>
<string name="fkm_explain">This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM.</string>
<string name="fkm_disable">Turn off</string>
<string name="notif_deleted_prefix">Deleted:</string>
<string name="share_target_label">Send to a chat</string>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowDisablePreview">true</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@color/launch_surface</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 11 и старше. Переписка, ключи и кэш медиа наружу не выгружаются. -->
<full-backup-content>
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 12 и новее: и облачный бэкап, и перенос на новое устройство. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="file" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,9 @@
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/nfc_service_description"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/nfc_aid_group_description"
android:category="other">
<aid-filter android:name="F04B4F4D455431"/>
</aid-group>
</host-apdu-service>
+15
View File
@@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity-alias android:name="ru.ikar.app.IntentEntry">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="max.ru"/>
<data android:scheme="https" android:host="www.max.ru"/>
<data android:scheme="http" android:host="max.ru"/>
<data android:scheme="http" android:host="www.max.ru"/>
</intent-filter>
</activity-alias>
</application>
</manifest>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+33
View File
@@ -0,0 +1,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
tools:node="remove" />
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
<activity-alias android:name="ru.ikar.app.IntentEntry">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="max.ru"/>
<data android:scheme="https" android:host="www.max.ru"/>
<data android:scheme="http" android:host="max.ru"/>
<data android:scheme="http" android:host="www.max.ru"/>
</intent-filter>
</activity-alias>
</application>
</manifest>
+23
View File
@@ -0,0 +1,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove"/>
<application>
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
tools:node="remove" />
<provider
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingInitProvider"
android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
tools:node="remove" />
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
</application>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+10
View File
@@ -0,0 +1,10 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
kotlin.incremental=false
dev.steenbakker.mobile_scanner.useUnbundled=true
android.ndk.suppressMinSdkVersionError=21
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+27
View File
@@ -0,0 +1,27 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.google.gms.google-services") version "4.4.3" apply false
}
include(":app")