Import IKAR messenger client and server
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||