Import IKAR messenger client and server
@@ -0,0 +1,47 @@
|
|||||||
|
<!--
|
||||||
|
Стиль оформления PR (заполняйте по этому шаблону).
|
||||||
|
|
||||||
|
Заголовок PR:
|
||||||
|
feat: краткое перечисление фич + scope → целевая ветка
|
||||||
|
Примеры:
|
||||||
|
feat: чаты, группы, файлы, real-time + security-фиксы #17 → dev/0.5.0
|
||||||
|
fix(ios): исключить Firebase из сборки iOS → dev/0.5.0
|
||||||
|
Префиксы: feat / fix / refactor / chore / docs (можно со scope, напр. fix(ios)).
|
||||||
|
|
||||||
|
Удалите неиспользуемые разделы. Описание — на русском языке.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
<!-- 1–3 предложения: что переносим и зачем. Укажите исходную и целевую ветку. -->
|
||||||
|
|
||||||
|
**Объём:** N файлов, +X / −Y.
|
||||||
|
|
||||||
|
## Что вошло
|
||||||
|
|
||||||
|
<!-- Группируйте по областям с эмодзи. Оставляйте только релевантные блоки. -->
|
||||||
|
|
||||||
|
### 💬 Чаты и сообщения
|
||||||
|
-
|
||||||
|
|
||||||
|
### 👤 Профиль и контакты
|
||||||
|
-
|
||||||
|
|
||||||
|
### 🔒 Безопасность
|
||||||
|
<!-- При наличии: ссылка на issue и номера находок. -->
|
||||||
|
-
|
||||||
|
|
||||||
|
### 🛠 Прочее
|
||||||
|
-
|
||||||
|
|
||||||
|
## Коммиты
|
||||||
|
|
||||||
|
<!-- Список `hash` + краткое описание. -->
|
||||||
|
- `xxxxxxx`
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- [ ] `flutter analyze`
|
||||||
|
- [ ] Сборка `qlyra` flavor
|
||||||
|
- [ ] Ручная проверка затронутых сценариев
|
||||||
|
- [ ] Регресс по безопасности (если затронута)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Build E2EE test library
|
||||||
|
description: Build and test the pinned C crypto core and expose it to Flutter tests.
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- shell: bash
|
||||||
|
run: |
|
||||||
|
cmake -S native/crypto-core -B build/native-tests/crypto-core -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build build/native-tests/crypto-core --config Debug --parallel 2
|
||||||
|
ctest --test-dir build/native-tests/crypto-core -C Debug --output-on-failure
|
||||||
|
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||||
|
echo "QLYRA_E2EE_LIBRARY=$GITHUB_WORKSPACE/build/native-tests/crypto-core/Debug/komet_crypto.dll" >> "$GITHUB_ENV"
|
||||||
|
elif [ "$RUNNER_OS" = "macOS" ]; then
|
||||||
|
echo "QLYRA_E2EE_LIBRARY=$GITHUB_WORKSPACE/build/native-tests/crypto-core/libkomet_crypto.dylib" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "QLYRA_E2EE_LIBRARY=$GITHUB_WORKSPACE/build/native-tests/crypto-core/libkomet_crypto.so" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Setup Rust
|
||||||
|
description: >
|
||||||
|
Install the Rust toolchain with platform targets and a build cache, so
|
||||||
|
cargokit can compile the native libraries during the Flutter build. kolibri
|
||||||
|
comes from pub.dev and builds inside the pub cache, so only the in-repo crate
|
||||||
|
is listed here; the shared cargo registry and git checkouts are cached anyway.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
targets:
|
||||||
|
description: Comma-separated rustup targets for the platform being built.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ inputs.targets }}
|
||||||
|
|
||||||
|
- name: Cache Rust build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
native/qlyra_crypto/rust
|
||||||
|
native/komet_tlottie/rust
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Android Emulator Smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dev/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
launch-qlyra:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build smoke APK
|
||||||
|
run: flutter build apk --debug --flavor ikar
|
||||||
|
|
||||||
|
- name: Verify launch and warm resume on emulator
|
||||||
|
uses: ReactiveCircus/android-emulator-runner@v2
|
||||||
|
with:
|
||||||
|
api-level: 35
|
||||||
|
arch: x86_64
|
||||||
|
profile: pixel_6
|
||||||
|
disable-animations: true
|
||||||
|
script: |
|
||||||
|
adb install -r build/app/outputs/flutter-apk/app-ikar-debug.apk
|
||||||
|
python3 scripts/check-android-resume.py --output artifacts/android-resume-smoke
|
||||||
|
adb shell pidof ru.ikar.app
|
||||||
|
|
||||||
|
- name: Upload Android resume diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-resume-smoke
|
||||||
|
path: artifacts/android-resume-smoke
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: Build Android (FCM)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'native/**'
|
||||||
|
- 'third_party/**'
|
||||||
|
- 'scripts/verify_android_signing.sh'
|
||||||
|
- '.github/workflows/build-android-fcm.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android-fcm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: dart format --output=none --set-exit-if-changed lib test
|
||||||
|
|
||||||
|
- name: Flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos lib test
|
||||||
|
|
||||||
|
- name: Install native plugin build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libclang-dev
|
||||||
|
|
||||||
|
- name: Prepare native plugin runtime for tests
|
||||||
|
run: dart run tool/prepare_plugin_runtime.dart
|
||||||
|
|
||||||
|
- name: Build E2EE test library
|
||||||
|
uses: ./.github/actions/setup-e2ee
|
||||||
|
|
||||||
|
- name: Build native crypto test library
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Flutter tests
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Setup release signing
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "Release signing secrets are required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > android/app/qlyra-release.jks
|
||||||
|
{
|
||||||
|
echo "storePassword=$KEYSTORE_PASSWORD"
|
||||||
|
echo "keyPassword=$KEY_PASSWORD"
|
||||||
|
echo "keyAlias=$KEY_ALIAS"
|
||||||
|
echo "storeFile=qlyra-release.jks"
|
||||||
|
} > android/key.properties
|
||||||
|
echo "Release signing configured."
|
||||||
|
|
||||||
|
- name: Build Universal APK
|
||||||
|
run: flutter build apk --release --flavor oneme --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Build Split APKs
|
||||||
|
run: flutter build apk --release --split-per-abi --flavor oneme --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify APK release certificates
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload Universal APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-fcm-universal
|
||||||
|
path: build/app/outputs/flutter-apk/app-oneme-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload arm64-v8a APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-fcm-arm64-v8a
|
||||||
|
path: build/app/outputs/flutter-apk/app-arm64-v8a-oneme-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload armeabi-v7a APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-fcm-armeabi-v7a
|
||||||
|
path: build/app/outputs/flutter-apk/app-armeabi-v7a-oneme-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload x86_64 APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-fcm-x86_64
|
||||||
|
path: build/app/outputs/flutter-apk/app-x86_64-oneme-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Build App Bundle
|
||||||
|
run: flutter build appbundle --release --flavor oneme --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify App Bundle release certificate
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload App Bundle artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-fcm-aab
|
||||||
|
path: build/app/outputs/bundle/onemeRelease/app-oneme-release.aab
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload debug symbols
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-fcm-symbols
|
||||||
|
path: build/symbols
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
name: Build Android (Google Play)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: dart format --output=none --set-exit-if-changed lib test
|
||||||
|
|
||||||
|
- name: Flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos lib test
|
||||||
|
|
||||||
|
- name: Install native plugin build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libclang-dev
|
||||||
|
|
||||||
|
- name: Prepare native plugin runtime for tests
|
||||||
|
run: dart run tool/prepare_plugin_runtime.dart
|
||||||
|
|
||||||
|
- name: Build E2EE test library
|
||||||
|
uses: ./.github/actions/setup-e2ee
|
||||||
|
|
||||||
|
- name: Build native crypto test library
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Flutter tests
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Setup release signing
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.PLAY_KEYSTORE_BASE64 || secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.PLAY_KEYSTORE_PASSWORD || secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.PLAY_KEY_PASSWORD || secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS || secrets.KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "Release signing secrets are required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > android/app/qlyra-release.jks
|
||||||
|
{
|
||||||
|
echo "storePassword=$KEYSTORE_PASSWORD"
|
||||||
|
echo "keyPassword=$KEY_PASSWORD"
|
||||||
|
echo "keyAlias=$KEY_ALIAS"
|
||||||
|
echo "storeFile=qlyra-release.jks"
|
||||||
|
} > android/key.properties
|
||||||
|
echo "Release signing configured."
|
||||||
|
|
||||||
|
- name: Build Universal APK
|
||||||
|
run: flutter build apk --release --flavor store --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify store manifest
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
manifest=$(find build/app/intermediates -path '*storeRelease*' -path '*merged_manifests*' -name AndroidManifest.xml -print -quit)
|
||||||
|
test -n "$manifest"
|
||||||
|
if grep -Eq 'REQUEST_INSTALL_PACKAGES|android:host="(www\.)?max\.ru"|firebaseinitprovider' "$manifest"; then
|
||||||
|
echo '::error::Store manifest contains forbidden entries'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Split APKs
|
||||||
|
run: flutter build apk --release --split-per-abi --flavor store --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify APK release certificates
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.PLAY_KEYSTORE_PASSWORD || secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS || secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload Universal APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-play-universal
|
||||||
|
path: build/app/outputs/flutter-apk/app-store-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload arm64-v8a APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-play-arm64-v8a
|
||||||
|
path: build/app/outputs/flutter-apk/app-arm64-v8a-store-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload armeabi-v7a APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-play-armeabi-v7a
|
||||||
|
path: build/app/outputs/flutter-apk/app-armeabi-v7a-store-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload x86_64 APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-play-x86_64
|
||||||
|
path: build/app/outputs/flutter-apk/app-x86_64-store-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Build App Bundle
|
||||||
|
run: flutter build appbundle --release --flavor store --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify App Bundle release certificate
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.PLAY_KEYSTORE_PASSWORD || secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS || secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload App Bundle artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-play-aab
|
||||||
|
path: build/app/outputs/bundle/storeRelease/app-store-release.aab
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload debug symbols
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-play-symbols
|
||||||
|
path: build/symbols
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: Build Android
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'android/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
|
- 'native/**'
|
||||||
|
- 'third_party/**'
|
||||||
|
- 'scripts/verify_android_signing.sh'
|
||||||
|
- '.github/workflows/build-android.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: dart format --output=none --set-exit-if-changed lib test
|
||||||
|
|
||||||
|
- name: Flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos lib test
|
||||||
|
|
||||||
|
- name: Install native plugin build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libclang-dev
|
||||||
|
|
||||||
|
- name: Prepare native plugin runtime for tests
|
||||||
|
run: dart run tool/prepare_plugin_runtime.dart
|
||||||
|
|
||||||
|
- name: Build E2EE test library
|
||||||
|
uses: ./.github/actions/setup-e2ee
|
||||||
|
|
||||||
|
- name: Build native crypto test library
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Flutter tests
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Setup release signing
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "Release signing secrets are required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > android/app/qlyra-release.jks
|
||||||
|
{
|
||||||
|
echo "storePassword=$KEYSTORE_PASSWORD"
|
||||||
|
echo "keyPassword=$KEY_PASSWORD"
|
||||||
|
echo "keyAlias=$KEY_ALIAS"
|
||||||
|
echo "storeFile=qlyra-release.jks"
|
||||||
|
} > android/key.properties
|
||||||
|
echo "Release signing configured."
|
||||||
|
|
||||||
|
- name: Build Universal APK
|
||||||
|
run: flutter build apk --release --flavor qlyra --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Build Split APKs
|
||||||
|
run: flutter build apk --release --split-per-abi --flavor qlyra --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify APK release certificates
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload Universal APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-universal
|
||||||
|
path: build/app/outputs/flutter-apk/app-qlyra-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload arm64-v8a APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-arm64-v8a
|
||||||
|
path: build/app/outputs/flutter-apk/app-arm64-v8a-qlyra-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload armeabi-v7a APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-armeabi-v7a
|
||||||
|
path: build/app/outputs/flutter-apk/app-armeabi-v7a-qlyra-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload x86_64 APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-x86_64
|
||||||
|
path: build/app/outputs/flutter-apk/app-x86_64-qlyra-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Build App Bundle
|
||||||
|
run: flutter build appbundle --release --flavor qlyra --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Verify App Bundle release certificate
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload App Bundle artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-aab
|
||||||
|
path: build/app/outputs/bundle/qlyraRelease/app-qlyra-release.aab
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload debug symbols
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-android-symbols
|
||||||
|
path: build/symbols
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Build iOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'ios/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-ios.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'ios/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-ios.yml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.44.3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build iOS (no codesign)
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build ios --release --no-codesign
|
||||||
|
|
||||||
|
- name: Package unsigned IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p dist build/ios/Payload
|
||||||
|
cp -R build/ios/iphoneos/Runner.app build/ios/Payload/
|
||||||
|
(cd build/ios && zip -qr ../../dist/Qlyra-ios-unsigned.ipa Payload)
|
||||||
|
rm -rf build/ios/Payload
|
||||||
|
|
||||||
|
- name: Upload unsigned iOS artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-ios-unsigned
|
||||||
|
path: dist/Qlyra-ios-unsigned.ipa
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Install ldid
|
||||||
|
run: brew install ldid
|
||||||
|
|
||||||
|
- name: Pseudo-sign with ldid
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
APP="build/ios/iphoneos/Runner.app"
|
||||||
|
ENT="$(mktemp -t ent).plist"
|
||||||
|
cat > "$ENT" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>get-task-allow</key><true/>
|
||||||
|
<key>application-identifier</key><string>ru.qlyra.app</string>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array><string>ru.qlyra.app</string></array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
if [ -d "$APP/Frameworks" ]; then
|
||||||
|
find "$APP/Frameworks" -type f -name "*.dylib" -print0 | xargs -0 -I{} ldid -S "{}"
|
||||||
|
find "$APP/Frameworks" -type d -name "*.framework" | while read -r fw; do
|
||||||
|
bin="$fw/$(basename "$fw" .framework)"
|
||||||
|
[ -f "$bin" ] && ldid -S "$bin"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -d "$APP/PlugIns" ]; then
|
||||||
|
find "$APP/PlugIns" -type d -name "*.appex" | while read -r ext; do
|
||||||
|
bin="$ext/$(basename "$ext" .appex)"
|
||||||
|
[ -f "$bin" ] && ldid -S"$ENT" "$bin"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
ldid -S"$ENT" "$APP/Runner"
|
||||||
|
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p dist build/ios/Payload
|
||||||
|
cp -R build/ios/iphoneos/Runner.app build/ios/Payload/
|
||||||
|
(cd build/ios && zip -qr ../../dist/Qlyra-ios-pseudosigned.ipa Payload)
|
||||||
|
|
||||||
|
- name: Upload iOS artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-ios-pseudosigned
|
||||||
|
path: dist/Qlyra-ios-pseudosigned.ipa
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Build Linux
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-linux.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'linux/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-linux.yml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.44.3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Install Linux desktop dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev libmpv-dev mpv libopus-dev libogg-dev
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build Linux
|
||||||
|
run: flutter build linux --release
|
||||||
|
|
||||||
|
- name: Package Linux
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
tar -C build/linux/x64/release/bundle -czf dist/Qlyra-linux-x64.tar.gz .
|
||||||
|
|
||||||
|
- name: Upload Linux artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-linux-x64
|
||||||
|
path: dist/Qlyra-linux-x64.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build macOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'macos/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-macos.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'macos/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-macos.yml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.44.3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Install create-dmg
|
||||||
|
run: brew install create-dmg
|
||||||
|
|
||||||
|
- name: Build signed DMG
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
./scripts/build_dmg.sh
|
||||||
|
|
||||||
|
- name: Upload macOS DMG artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-macos
|
||||||
|
path: dist/*.dmg
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'windows/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-windows.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'lib/**'
|
||||||
|
- 'windows/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- '.github/workflows/build-windows.yml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.44.3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
run: flutter build windows --release
|
||||||
|
|
||||||
|
- name: Package Windows
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
|
Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath dist/Qlyra-windows-x64.zip
|
||||||
|
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: qlyra-windows-x64
|
||||||
|
path: dist/Qlyra-windows-x64.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
name: Flutter Dev CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'dev/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze-and-build-android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: dart format --output=none --set-exit-if-changed lib test
|
||||||
|
|
||||||
|
- name: Flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos lib test
|
||||||
|
|
||||||
|
- name: Install native plugin build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libclang-dev
|
||||||
|
|
||||||
|
- name: Prepare native plugin runtime for tests
|
||||||
|
run: dart run tool/prepare_plugin_runtime.dart
|
||||||
|
|
||||||
|
- name: Build E2EE test library
|
||||||
|
uses: ./.github/actions/setup-e2ee
|
||||||
|
|
||||||
|
- name: Build native crypto test library
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Flutter tests with coverage
|
||||||
|
run: flutter test --coverage
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: flutter-dev-coverage
|
||||||
|
path: coverage/lcov.info
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: flutter build apk --debug --flavor qlyra
|
||||||
|
|
||||||
|
build-ios:
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: dart format --output=none --set-exit-if-changed lib test
|
||||||
|
|
||||||
|
- name: Install native plugin build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libclang-dev
|
||||||
|
|
||||||
|
- name: Prepare native plugin runtime for tests
|
||||||
|
run: dart run tool/prepare_plugin_runtime.dart
|
||||||
|
|
||||||
|
- name: Build E2EE test library
|
||||||
|
uses: ./.github/actions/setup-e2ee
|
||||||
|
|
||||||
|
- name: Build native crypto test library
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Flutter tests
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
- name: Build iOS (no codesign)
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build ios --release --no-codesign
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
name: Flutter Main CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify formatting
|
||||||
|
run: dart format --output=none --set-exit-if-changed lib test
|
||||||
|
|
||||||
|
- name: Flutter analyze
|
||||||
|
run: flutter analyze --no-fatal-infos lib test
|
||||||
|
|
||||||
|
- name: Install native plugin build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libclang-dev
|
||||||
|
|
||||||
|
- name: Prepare native plugin runtime for tests
|
||||||
|
run: dart run tool/prepare_plugin_runtime.dart
|
||||||
|
|
||||||
|
- name: Build E2EE test library
|
||||||
|
uses: ./.github/actions/setup-e2ee
|
||||||
|
|
||||||
|
- name: Build native crypto test library
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Flutter tests with coverage
|
||||||
|
run: flutter test --coverage
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: flutter-main-coverage
|
||||||
|
path: coverage/lcov.info
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: flutter build apk --debug --flavor qlyra
|
||||||
|
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Install Linux desktop dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build Linux
|
||||||
|
run: flutter build linux --release
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
run: flutter build windows --release
|
||||||
|
|
||||||
|
apple:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Build iOS
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build ios --release --no-codesign
|
||||||
|
|
||||||
|
- name: Fix ogg_opus_player macOS libs
|
||||||
|
run: ./scripts/fix_opus_macos.sh
|
||||||
|
|
||||||
|
- name: Build macOS
|
||||||
|
run: flutter build macos --release
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Native crypto
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
native-crypto:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
cache: true
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: cargo test --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
- run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
- run: flutter test --no-pub test/chat_crypto_roundtrip_test.dart test/outbox_queue_test.dart test/message_persistence_test.dart
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Dependency Vulnerability Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'dev/**'
|
||||||
|
merge_group:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: '30 2 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.5.0
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--lockfile=./pubspec.lock
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
name: Pre-release (dev/0.5.0)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
build_number:
|
||||||
|
description: 'Override build number (e.g. 13). Leave empty to use the auto run number.'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'dev/0.5.0'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.44.3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor: [qlyra, oneme]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify native crypto and message delivery
|
||||||
|
run: |
|
||||||
|
cargo test --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
flutter test --no-pub test/chat_crypto_roundtrip_test.dart test/outbox_queue_test.dart test/message_persistence_test.dart test/messages_delivery_test.dart
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Setup release signing
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "Release signing secrets are required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > android/app/qlyra-release.jks
|
||||||
|
{
|
||||||
|
echo "storePassword=$KEYSTORE_PASSWORD"
|
||||||
|
echo "keyPassword=$KEY_PASSWORD"
|
||||||
|
echo "keyAlias=$KEY_ALIAS"
|
||||||
|
echo "storeFile=qlyra-release.jks"
|
||||||
|
} > android/key.properties
|
||||||
|
echo "Release signing configured."
|
||||||
|
|
||||||
|
- name: Build APKs and App Bundle (${{ matrix.flavor }})
|
||||||
|
run: |
|
||||||
|
flutter build apk --release --flavor ${{ matrix.flavor }} --obfuscate --split-debug-info=build/symbols
|
||||||
|
flutter build apk --release --split-per-abi --flavor ${{ matrix.flavor }} --obfuscate --split-debug-info=build/symbols
|
||||||
|
flutter build appbundle --release --flavor ${{ matrix.flavor }} --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Collect Android artifacts (${{ matrix.flavor }})
|
||||||
|
run: |
|
||||||
|
F=${{ matrix.flavor }}
|
||||||
|
mkdir -p dist
|
||||||
|
cp build/app/outputs/flutter-apk/app-$F-release.apk dist/Qlyra-android-$F-universal.apk
|
||||||
|
cp build/app/outputs/flutter-apk/app-arm64-v8a-$F-release.apk dist/Qlyra-android-$F-arm64-v8a.apk
|
||||||
|
cp build/app/outputs/flutter-apk/app-armeabi-v7a-$F-release.apk dist/Qlyra-android-$F-armeabi-v7a.apk
|
||||||
|
cp build/app/outputs/flutter-apk/app-x86_64-$F-release.apk dist/Qlyra-android-$F-x86_64.apk
|
||||||
|
cp build/app/outputs/bundle/${F}Release/app-$F-release.aab dist/Qlyra-android-$F.aab
|
||||||
|
tar -czf dist/Qlyra-android-$F-symbols.tar.gz -C build/symbols .
|
||||||
|
|
||||||
|
- name: Verify APK release certificates
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload Android artifacts (${{ matrix.flavor }})
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-${{ matrix.flavor }}
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build Windows
|
||||||
|
run: flutter build windows --release
|
||||||
|
- name: Package Windows
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
|
Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath dist/Qlyra-windows-x64.zip
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Install Linux desktop dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev libmpv-dev mpv libopus-dev libogg-dev
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build Linux
|
||||||
|
run: flutter build linux --release
|
||||||
|
- name: Package Linux
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
tar -C build/linux/x64/release/bundle -czf dist/Qlyra-linux-x64.tar.gz .
|
||||||
|
- name: Upload Linux artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Fix opus/ogg libs for macOS
|
||||||
|
run: ./scripts/fix_opus_macos.sh
|
||||||
|
- name: Build macOS
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build macos --release
|
||||||
|
- name: Package macOS
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
APP=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -1)
|
||||||
|
ditto -c -k --keepParent "$APP" dist/Qlyra-macos.zip
|
||||||
|
- name: Upload macOS artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
ios:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build iOS (no codesign)
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build ios --release --no-codesign
|
||||||
|
- name: Package unsigned IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p dist build/ios/Payload
|
||||||
|
cp -R build/ios/iphoneos/Runner.app build/ios/Payload/
|
||||||
|
(cd build/ios && zip -qr ../../dist/Qlyra-ios-unsigned.ipa Payload)
|
||||||
|
rm -rf build/ios/Payload
|
||||||
|
- name: Install ldid
|
||||||
|
run: brew install ldid
|
||||||
|
- name: Pseudo-sign with ldid
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
APP="build/ios/iphoneos/Runner.app"
|
||||||
|
ENT="$(mktemp -t ent).plist"
|
||||||
|
cat > "$ENT" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>platform-application</key><true/>
|
||||||
|
<key>get-task-allow</key><true/>
|
||||||
|
<key>application-identifier</key><string>ru.qlyra.app</string>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array><string>ru.qlyra.app</string></array>
|
||||||
|
<key>com.apple.private.security.no-container</key><true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
if [ -d "$APP/Frameworks" ]; then
|
||||||
|
find "$APP/Frameworks" -type f -name "*.dylib" -print0 | xargs -0 -I{} ldid -S "{}"
|
||||||
|
find "$APP/Frameworks" -type d -name "*.framework" | while read -r fw; do
|
||||||
|
bin="$fw/$(basename "$fw" .framework)"
|
||||||
|
[ -f "$bin" ] && ldid -S "$bin"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -d "$APP/PlugIns" ]; then
|
||||||
|
find "$APP/PlugIns" -type d -name "*.appex" | while read -r ext; do
|
||||||
|
bin="$ext/$(basename "$ext" .appex)"
|
||||||
|
[ -f "$bin" ] && ldid -S"$ENT" "$bin"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
ldid -S"$ENT" "$APP/Runner"
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p dist build/ios/Payload
|
||||||
|
cp -R build/ios/iphoneos/Runner.app build/ios/Payload/
|
||||||
|
(cd build/ios && zip -qr ../../dist/Qlyra-ios-pseudosigned.ipa Payload)
|
||||||
|
- name: Upload iOS artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [android, windows, linux, macos, ios]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
BRANCH_VERSION="${GITHUB_REF_NAME#dev/}"
|
||||||
|
BUILD="${{ github.event.inputs.build_number }}"
|
||||||
|
BUILD="${BUILD:-$GITHUB_RUN_NUMBER}"
|
||||||
|
echo "tag=v${BRANCH_VERSION}-dev.${BUILD}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "name=Qlyra ${BRANCH_VERSION}-dev (build ${BUILD})" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: List artifacts
|
||||||
|
run: ls -lhR dist
|
||||||
|
|
||||||
|
- name: Create pre-release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.ver.outputs.tag }}
|
||||||
|
name: ${{ steps.ver.outputs.name }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
prerelease: true
|
||||||
|
generate_release_notes: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
files: dist/*
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
name: Release (main)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.44.3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
flavor: [qlyra, oneme]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
cache: 'gradle'
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Verify native crypto and message delivery
|
||||||
|
run: |
|
||||||
|
cargo test --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
flutter test --no-pub test/chat_crypto_roundtrip_test.dart test/outbox_queue_test.dart test/message_persistence_test.dart test/messages_delivery_test.dart
|
||||||
|
|
||||||
|
- name: Configure Gradle
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.gradle
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.caching=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.parallel=true" >> ~/.gradle/gradle.properties
|
||||||
|
echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties
|
||||||
|
|
||||||
|
- name: Setup release signing
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "Release signing secrets are required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > android/app/qlyra-release.jks
|
||||||
|
{
|
||||||
|
echo "storePassword=$KEYSTORE_PASSWORD"
|
||||||
|
echo "keyPassword=$KEY_PASSWORD"
|
||||||
|
echo "keyAlias=$KEY_ALIAS"
|
||||||
|
echo "storeFile=qlyra-release.jks"
|
||||||
|
} > android/key.properties
|
||||||
|
echo "Release signing configured."
|
||||||
|
|
||||||
|
- name: Build APKs and App Bundle (${{ matrix.flavor }})
|
||||||
|
run: |
|
||||||
|
flutter build apk --release --flavor ${{ matrix.flavor }} --obfuscate --split-debug-info=build/symbols
|
||||||
|
flutter build apk --release --split-per-abi --flavor ${{ matrix.flavor }} --obfuscate --split-debug-info=build/symbols
|
||||||
|
flutter build appbundle --release --flavor ${{ matrix.flavor }} --obfuscate --split-debug-info=build/symbols
|
||||||
|
|
||||||
|
- name: Collect Android artifacts (${{ matrix.flavor }})
|
||||||
|
run: |
|
||||||
|
F=${{ matrix.flavor }}
|
||||||
|
mkdir -p dist
|
||||||
|
cp build/app/outputs/flutter-apk/app-$F-release.apk dist/Qlyra-android-$F-universal.apk
|
||||||
|
cp build/app/outputs/flutter-apk/app-arm64-v8a-$F-release.apk dist/Qlyra-android-$F-arm64-v8a.apk
|
||||||
|
cp build/app/outputs/flutter-apk/app-armeabi-v7a-$F-release.apk dist/Qlyra-android-$F-armeabi-v7a.apk
|
||||||
|
cp build/app/outputs/flutter-apk/app-x86_64-$F-release.apk dist/Qlyra-android-$F-x86_64.apk
|
||||||
|
cp build/app/outputs/bundle/${F}Release/app-$F-release.aab dist/Qlyra-android-$F.aab
|
||||||
|
tar -czf dist/Qlyra-android-$F-symbols.tar.gz -C build/symbols .
|
||||||
|
|
||||||
|
- name: Verify APK release certificates
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
run: bash scripts/verify_android_signing.sh
|
||||||
|
|
||||||
|
- name: Upload Android artifacts (${{ matrix.flavor }})
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-${{ matrix.flavor }}
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-pc-windows-msvc
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build Windows
|
||||||
|
run: flutter build windows --release
|
||||||
|
- name: Package Windows
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
|
Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath dist/Qlyra-windows-x64.zip
|
||||||
|
- name: Upload Windows artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Install Linux desktop dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: x86_64-unknown-linux-gnu
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build Linux
|
||||||
|
run: flutter build linux --release
|
||||||
|
- name: Package Linux
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
tar -C build/linux/x64/release/bundle -czf dist/Qlyra-linux-x64.tar.gz .
|
||||||
|
- name: Upload Linux artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build macOS
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build macos --release
|
||||||
|
- name: Package macOS
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
APP=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -1)
|
||||||
|
ditto -c -k --keepParent "$APP" dist/Qlyra-macos.zip
|
||||||
|
- name: Upload macOS artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
ios:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
- name: Setup Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: 'stable'
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: ./.github/actions/setup-rust
|
||||||
|
with:
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
- name: Build iOS (no codesign)
|
||||||
|
run: |
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
flutter build ios --release --no-codesign
|
||||||
|
- name: Install ldid
|
||||||
|
run: brew install ldid
|
||||||
|
- name: Pseudo-sign with ldid
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
APP="build/ios/iphoneos/Runner.app"
|
||||||
|
ENT="$(mktemp -t ent).plist"
|
||||||
|
cat > "$ENT" <<'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>platform-application</key><true/>
|
||||||
|
<key>get-task-allow</key><true/>
|
||||||
|
<key>application-identifier</key><string>ru.qlyra.app</string>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array><string>ru.qlyra.app</string></array>
|
||||||
|
<key>com.apple.private.security.no-container</key><true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
if [ -d "$APP/Frameworks" ]; then
|
||||||
|
find "$APP/Frameworks" -type f -name "*.dylib" -print0 | xargs -0 -I{} ldid -S "{}"
|
||||||
|
find "$APP/Frameworks" -type d -name "*.framework" | while read -r fw; do
|
||||||
|
bin="$fw/$(basename "$fw" .framework)"
|
||||||
|
[ -f "$bin" ] && ldid -S "$bin"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -d "$APP/PlugIns" ]; then
|
||||||
|
find "$APP/PlugIns" -type d -name "*.appex" | while read -r ext; do
|
||||||
|
bin="$ext/$(basename "$ext" .appex)"
|
||||||
|
[ -f "$bin" ] && ldid -S"$ENT" "$bin"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
ldid -S"$ENT" "$APP/Runner"
|
||||||
|
- name: Package IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p dist build/ios/Payload
|
||||||
|
cp -R build/ios/iphoneos/Runner.app build/ios/Payload/
|
||||||
|
(cd build/ios && zip -qr ../../dist/Qlyra-ios-pseudosigned.ipa Payload)
|
||||||
|
- name: Upload iOS artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [android, windows, linux, macos, ios]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Compute version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
PUBSPEC_VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)
|
||||||
|
echo "tag=v${PUBSPEC_VERSION}+${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "name=Qlyra ${PUBSPEC_VERSION} (сборка ${GITHUB_RUN_NUMBER})" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: List artifacts
|
||||||
|
run: ls -lhR dist
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.ver.outputs.tag }}
|
||||||
|
name: ${{ steps.ver.outputs.name }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
prerelease: false
|
||||||
|
generate_release_notes: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
body: |
|
||||||
|
Автоматическая сборка из ветки `${{ github.ref_name }}` @ `${{ github.sha }}`.
|
||||||
|
|
||||||
|
## Какой файл качать
|
||||||
|
|
||||||
|
### Android — версия Qlyra (без push-уведомлений)
|
||||||
|
- **`Qlyra-android-qlyra-arm64-v8a.apk`** — рекомендуется для большинства современных телефонов (64-битный ARM).
|
||||||
|
- `Qlyra-android-qlyra-universal.apk` — универсальный APK, работает везде, но весит больше.
|
||||||
|
- `Qlyra-android-qlyra-armeabi-v7a.apk` — для старых 32-битных устройств.
|
||||||
|
- `Qlyra-android-qlyra-x86_64.apk` — для эмуляторов и редких x86-устройств.
|
||||||
|
- `Qlyra-android-qlyra.aab` — App Bundle для публикации в Google Play (не для ручной установки).
|
||||||
|
|
||||||
|
### Android — версия OneMe (с push-уведомлениями через Firebase)
|
||||||
|
- **`Qlyra-android-oneme-arm64-v8a.apk`** — рекомендуется для большинства современных телефонов.
|
||||||
|
- `Qlyra-android-oneme-universal.apk` — универсальный APK.
|
||||||
|
- `Qlyra-android-oneme-armeabi-v7a.apk` — для старых 32-битных устройств.
|
||||||
|
- `Qlyra-android-oneme-x86_64.apk` — для эмуляторов и x86-устройств.
|
||||||
|
- `Qlyra-android-oneme.aab` — App Bundle для Google Play.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
- **`Qlyra-windows-x64.zip`** — портативная сборка для Windows 10/11 (x64). Распаковать и запустить `Qlyra.exe`.
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
- **`Qlyra-linux-x64.tar.gz`** — сборка для 64-битного Linux. Требуются `libgtk-3`, `libsecret-1`, `libjsoncpp`. Распаковать и запустить бинарник.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
- **`Qlyra-macos.zip`** — `.app` для macOS. Распаковать и перетащить в `/Applications`. Приложение не нотаризовано — при первом запуске разрешите через «Системные настройки → Защита и безопасность».
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
- **`Qlyra-ios-pseudosigned.ipa`** — IPA с псевдо-подписью (ldid).
|
||||||
|
⚠️ Не устанавливается через App Store. Подходит для:
|
||||||
|
- jailbreak (Filza, rootless),
|
||||||
|
- TrollStore,
|
||||||
|
- сайдлоада через AltStore / Sideloadly (потребуется ваш Apple ID для повторной подписи).
|
||||||
|
files: dist/*
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.44.3'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build crypto core
|
||||||
|
run: make -C native/crypto-core shared
|
||||||
|
|
||||||
|
- name: Build Qlyra crypto
|
||||||
|
run: cargo build --locked --manifest-path native/qlyra_crypto/rust/Cargo.toml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Flutter test
|
||||||
|
run: flutter test
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
|
||||||
|
# IDE - IntelliJ / Android Studio
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Flutter metadata
|
||||||
|
.metadata
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Flutter repo-specific
|
||||||
|
/bin/cache/
|
||||||
|
/bin/internal/bootstrap.bat
|
||||||
|
/bin/internal/bootstrap.sh
|
||||||
|
/bin/mingit/
|
||||||
|
/dev/benchmarks/mega_gallery/
|
||||||
|
/dev/bots/.recipe_deps
|
||||||
|
/dev/bots/android_tools/
|
||||||
|
/dev/devicelab/ABresults*.json
|
||||||
|
/dev/docs/doc/
|
||||||
|
/dev/docs/flutter.docs.zip
|
||||||
|
/dev/docs/lib/
|
||||||
|
/dev/docs/pubspec.yaml
|
||||||
|
/dev/integration_tests/**/xcuserdata
|
||||||
|
/dev/integration_tests/**/Pods
|
||||||
|
/packages/flutter/coverage/
|
||||||
|
version
|
||||||
|
analysis_benchmark.json
|
||||||
|
|
||||||
|
# packages file containing multi-root paths
|
||||||
|
.packages.generated
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
**/generated_plugin_registrant.dart
|
||||||
|
.packages
|
||||||
|
.pub-preload-cache/
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
flutter_*.png
|
||||||
|
linked_*.ds
|
||||||
|
unlinked.ds
|
||||||
|
unlinked_spec.ds
|
||||||
|
|
||||||
|
# Android related
|
||||||
|
**/android/**/gradle-wrapper.jar
|
||||||
|
.gradle/
|
||||||
|
**/android/captures/
|
||||||
|
**/android/gradlew
|
||||||
|
**/android/gradlew.bat
|
||||||
|
**/android/local.properties
|
||||||
|
**/android/**/GeneratedPluginRegistrant.java
|
||||||
|
**/android/key.properties
|
||||||
|
*.jks
|
||||||
|
|
||||||
|
# iOS/XCode related
|
||||||
|
**/ios/**/*.mode1v3
|
||||||
|
**/ios/**/*.mode2v3
|
||||||
|
**/ios/**/*.moved-aside
|
||||||
|
**/ios/**/*.pbxuser
|
||||||
|
**/ios/**/*.perspectivev3
|
||||||
|
**/ios/**/*sync/
|
||||||
|
**/ios/**/.sconsign.dblite
|
||||||
|
**/ios/**/.tags*
|
||||||
|
**/ios/**/.vagrant/
|
||||||
|
**/ios/**/DerivedData/
|
||||||
|
**/ios/**/Icon?
|
||||||
|
**/ios/**/Pods/
|
||||||
|
**/ios/**/.symlinks/
|
||||||
|
**/ios/**/profile
|
||||||
|
**/ios/**/xcuserdata
|
||||||
|
**/ios/.generated/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
**/ios/Flutter/App.framework
|
||||||
|
**/ios/Flutter/Flutter.framework
|
||||||
|
**/ios/Flutter/Flutter.podspec
|
||||||
|
**/ios/Flutter/Generated.xcconfig
|
||||||
|
**/ios/Flutter/ephemeral
|
||||||
|
**/ios/Flutter/app.flx
|
||||||
|
**/ios/Flutter/app.zip
|
||||||
|
**/ios/Flutter/flutter_assets/
|
||||||
|
**/ios/Flutter/flutter_export_environment.sh
|
||||||
|
**/ios/ServiceDefinitions.json
|
||||||
|
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
**/Flutter/ephemeral/
|
||||||
|
**/Pods/
|
||||||
|
**/macos/Flutter/GeneratedPluginRegistrant.swift
|
||||||
|
**/macos/Flutter/ephemeral
|
||||||
|
**/xcuserdata/
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
**/windows/flutter/generated_plugin_registrant.cc
|
||||||
|
**/windows/flutter/generated_plugin_registrant.h
|
||||||
|
**/windows/flutter/generated_plugins.cmake
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
**/linux/flutter/generated_plugin_registrant.cc
|
||||||
|
**/linux/flutter/generated_plugin_registrant.h
|
||||||
|
**/linux/flutter/generated_plugins.cmake
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Symbols
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!**/ios/**/default.mode1v3
|
||||||
|
!**/ios/**/default.mode2v3
|
||||||
|
!**/ios/**/default.pbxuser
|
||||||
|
!**/ios/**/default.perspectivev3
|
||||||
|
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||||
|
!/dev/ci/**/Gemfile.lock
|
||||||
|
|
||||||
|
# AI / Agents
|
||||||
|
agents.md
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
# Локальные дампы трафика и скрипты анализа (содержат секреты)
|
||||||
|
qlyra.txt
|
||||||
|
original_app.txt
|
||||||
|
fingerprint.py
|
||||||
|
PCAPdroid_*.txt
|
||||||
|
|
||||||
|
# Локальные рабочие файлы и эксперименты (не для репозитория)
|
||||||
|
PR_FullStack.md
|
||||||
|
docs/
|
||||||
|
maxmint/
|
||||||
|
maxtun/
|
||||||
|
turnprobe/
|
||||||
|
test/live_server_probe_test.dart
|
||||||
|
|
||||||
|
pubspec_overrides.yaml
|
||||||
|
.cargo/
|
||||||
|
**/target/
|
||||||
|
.codex-temp/
|
||||||
|
|
||||||
|
# IKAR local secrets and generated artifacts
|
||||||
|
.secrets/
|
||||||
|
.migration/
|
||||||
|
*.log
|
||||||
|
ikar-*.tgz
|
||||||
|
ikar_server/**/bin/
|
||||||
|
ikar_server/**/obj/
|
||||||
|
ikar_server/publish/
|
||||||
|
ikar_server/.env*
|
||||||
|
!ikar_server/.env.example
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# IKAR local secrets and generated artifacts
|
||||||
|
.secrets/
|
||||||
|
.migration/
|
||||||
|
*.log
|
||||||
|
ikar-*.tgz
|
||||||
|
ikar_server/**/bin/
|
||||||
|
ikar_server/**/obj/
|
||||||
|
ikar_server/publish/
|
||||||
|
ikar_server/.env*
|
||||||
|
!ikar_server/.env.example
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Local source snapshots
|
||||||
|
*.before-*
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "third_party/rlottie"]
|
||||||
|
path = third_party/rlottie
|
||||||
|
url = https://github.com/Samsung/rlottie.git
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Android permission matrix
|
||||||
|
|
||||||
|
The machine-readable source of truth is [`tool/android_permissions.json`](tool/android_permissions.json). CI tests require every permission in the main Android manifest to have one owner, feature and trigger.
|
||||||
|
|
||||||
|
Triggers have these meanings:
|
||||||
|
|
||||||
|
- `user_action`: Android permission or settings UI may be requested only after a related tap, toggle or confirmed WebView prompt.
|
||||||
|
- `feature_enabled`: background behavior may start only after the user enables the feature.
|
||||||
|
- `active_feature`: the permission is used only while the related feature is active.
|
||||||
|
- `system`: install-time capability without a runtime prompt.
|
||||||
|
|
||||||
|
| Area | Permissions | User-visible entry point |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Transport | `INTERNET`, `ACCESS_NETWORK_STATE` | Login and normal messaging |
|
||||||
|
| Updates | `REQUEST_INSTALL_PACKAGES` | Confirmed update installation |
|
||||||
|
| Camera and audio | `CAMERA`, `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS` | Attachment camera, voice/video note, QR scanner or call |
|
||||||
|
| Location | `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` | Location attachment; legacy BLE contact exchange |
|
||||||
|
| Bluetooth and NFC | `BLUETOOTH*`, `NFC` | Contact exchange sheet |
|
||||||
|
| Notifications | `POST_NOTIFICATIONS`, `VIBRATE`, `WAKE_LOCK` | Notification settings or active notification feature |
|
||||||
|
| Foreground services | `FOREGROUND_SERVICE*` | Enabled FKM, active call, screen sharing, or audio playback started by the user |
|
||||||
|
| Background startup | `RECEIVE_BOOT_COMPLETED` | Previously enabled FKM only |
|
||||||
|
| Special settings | `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `USE_FULL_SCREEN_INTENT` | Separate confirmation in notification settings |
|
||||||
|
| Media library | `READ_EXTERNAL_STORAGE`, `READ_MEDIA_*` | Attachment picker or explicit save action |
|
||||||
|
| Contacts | `READ_CONTACTS` | Optional phonebook-name feature |
|
||||||
|
|
||||||
|
WebView camera, microphone and location requests additionally require an HTTPS allowlisted origin and a per-request confirmation dialog.
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Плагины Komet
|
||||||
|
|
||||||
|
Плагин добавляет в чат slash-команды. Пользователь пишет `/weather Москва`, Komet
|
||||||
|
запускает обработчик плагина в изолированном движке JavaScript, и тот работает с
|
||||||
|
чатом через модуль `komet:api`.
|
||||||
|
|
||||||
|
Плагин распространяется файлом `.kinet` — это zip-архив с `manifest.json` и
|
||||||
|
ES-модулями. Установка: настройки Komet → «Плагины» → «Установить .kinet» или
|
||||||
|
«Установить по URL».
|
||||||
|
|
||||||
|
В этом репозитории:
|
||||||
|
|
||||||
|
| Файл | Что это |
|
||||||
|
|------------------------------------------|-------------------------------------------|
|
||||||
|
| `plugin_sdk/komet-api.d.ts` | типы `komet:api` для подсказок в редакторе |
|
||||||
|
| `plugin_sdk/manifest.schema.json` | JSON Schema для `manifest.json` |
|
||||||
|
| `plugin_sdk/update-manifest.schema.json` | JSON Schema для манифеста обновлений |
|
||||||
|
| `tool/kinet_sign.dart` | ключи и подпись пакетов |
|
||||||
|
| `assets/plugins/` | встроенные плагины — готовые примеры |
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```
|
||||||
|
hello/
|
||||||
|
├── manifest.json
|
||||||
|
└── main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`manifest.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/KometTeam/Komet/main/plugin_sdk/manifest.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "com.example.hello",
|
||||||
|
"name": "Hello",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": 1,
|
||||||
|
"description": "Приветствие",
|
||||||
|
"author": "example",
|
||||||
|
"main": "main.js",
|
||||||
|
"permissions": ["chat.write"],
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "/hello",
|
||||||
|
"description": "поздороваться",
|
||||||
|
"handler": "hello",
|
||||||
|
"arguments": [
|
||||||
|
{"name": "name", "description": "кого приветствовать", "required": false, "rest": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`main.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { chat } from 'komet:api';
|
||||||
|
|
||||||
|
/** @param {import('komet:api').CommandContext} context */
|
||||||
|
export async function hello(context) {
|
||||||
|
const name = context.arguments.name || 'мир';
|
||||||
|
await chat.sendText(`Привет, ${name}!`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Сборка пакета — `manifest.json` должен лежать в корне архива:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hello && zip -r ../hello.kinet . -i 'manifest.json' '*.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
Для подсказок в редакторе держите `komet-api.d.ts` рядом с проектом, но не кладите в
|
||||||
|
пакет: в `.kinet` допустимы только `manifest.json` и файлы `.js`.
|
||||||
|
|
||||||
|
## Манифест
|
||||||
|
|
||||||
|
| Поле | Обязательно | Описание |
|
||||||
|
|-----------------|-------------|----------|
|
||||||
|
| `schemaVersion` | да | всегда `1` |
|
||||||
|
| `id` | да | уникальный id в обратной доменной записи: `com.example.hello`. Префикс `pw.komet.` занят встроенными плагинами |
|
||||||
|
| `name` | да | название в списке плагинов |
|
||||||
|
| `version` | да | SemVer: `1.2.3`, `1.2.3-beta.1`, `1.2.3+5` |
|
||||||
|
| `apiVersion` | да | версия `komet:api`, которую ждёт плагин. Сейчас `1`; плагин с большей версией не установится |
|
||||||
|
| `description` | нет | описание в списке плагинов |
|
||||||
|
| `author` | нет | автор |
|
||||||
|
| `main` | да | путь к главному модулю внутри пакета, `.js` |
|
||||||
|
| `permissions` | да | разрешения, см. ниже. Может быть пустым массивом |
|
||||||
|
| `commands` | да | хотя бы одна команда |
|
||||||
|
| `updateUrl` | нет | HTTPS-адрес манифеста обновлений, см. «Обновления» |
|
||||||
|
| `signature` | нет | подпись; её добавляет `tool/kinet_sign.dart` |
|
||||||
|
|
||||||
|
### Команды
|
||||||
|
|
||||||
|
| Поле | Обязательно | Описание |
|
||||||
|
|---------------|-------------|----------|
|
||||||
|
| `name` | да | `/` и латиница, цифры, `_`, `-`, до 32 символов после `/`. Ведущий `/` можно не писать |
|
||||||
|
| `description` | да | текст в подсказке команд |
|
||||||
|
| `handler` | да | имя функции, экспортированной из `main` |
|
||||||
|
| `arguments` | нет | аргументы, см. ниже |
|
||||||
|
| `hidden` | нет | `true` — команда работает, но не показывается в подсказках |
|
||||||
|
|
||||||
|
Имена команд внутри плагина не должны повторяться без учёта регистра. Если имя уже
|
||||||
|
занято встроенной командой или другим плагином, команда не появится.
|
||||||
|
|
||||||
|
### Аргументы
|
||||||
|
|
||||||
|
| Поле | По умолчанию | Описание |
|
||||||
|
|---------------|--------------|----------|
|
||||||
|
| `name` | — | латиница, цифры, `_`, `-`, до 32 символов, начинается с буквы |
|
||||||
|
| `description` | `""` | подпись поля ввода |
|
||||||
|
| `required` | `true` | без значения команда не запустится, Komet покажет формат вызова |
|
||||||
|
| `rest` | `false` | забирает весь остаток строки; только у последнего аргумента |
|
||||||
|
|
||||||
|
Аргументы разделяются пробелами. Значение с пробелами берут в двойные или одинарные
|
||||||
|
кавычки, внутри кавычек `\` экранирует следующий символ. Если у команды есть
|
||||||
|
аргументы, при её выборе в подсказке Komet показывает поле для каждого из них.
|
||||||
|
|
||||||
|
## Контекст команды
|
||||||
|
|
||||||
|
Обработчик получает один объект:
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|--------------|--------------------------|----------|
|
||||||
|
| `args` | `string` | всё, что написано после имени команды |
|
||||||
|
| `arguments` | `Record<string, string>` | значения аргументов по имени; не заданные — пустая строка |
|
||||||
|
| `reply` | `object \| null` | сообщение, на которое отвечает пользователь. Только с разрешением `message.readReply`, иначе `null` |
|
||||||
|
| `apiVersion` | `number` | версия `komet:api` в приложении |
|
||||||
|
|
||||||
|
`reply`: `{ id, senderId, text, time, attachments: [{ type }] }`. `time` — миллисекунды
|
||||||
|
Unix, `text` может быть `null`, `type` — `photo`, `video`, `audio`, `file`, `sticker` и
|
||||||
|
другие значения из `komet-api.d.ts`.
|
||||||
|
|
||||||
|
## Разрешения
|
||||||
|
|
||||||
|
Пользователь видит список разрешений при установке и выдаёт их все сразу. Вызов без
|
||||||
|
нужного разрешения завершается ошибкой `Нет разрешения <id>`.
|
||||||
|
|
||||||
|
| Разрешение | Что даёт |
|
||||||
|
|---------------------|----------|
|
||||||
|
| `chat.write` | `chat.sendText` |
|
||||||
|
| `chat.edit` | `chat.editText` |
|
||||||
|
| `chat.photo` | `chat.sendPhoto` |
|
||||||
|
| `chat.file` | `chat.sendFile` |
|
||||||
|
| `ui.notify` | `ui.notify` |
|
||||||
|
| `contact.read` | `contact.getPeer` |
|
||||||
|
| `message.readReply` | `context.reply` |
|
||||||
|
| `network` | `network.fetch` и загрузка по `url` в `chat.sendPhoto` / `chat.sendFile` |
|
||||||
|
| `storage` | `storage.get`, `storage.set`, `storage.remove` |
|
||||||
|
|
||||||
|
Методы `runtime.*` разрешений не требуют.
|
||||||
|
|
||||||
|
## API `komet:api`
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { chat, network, ui, contact, runtime, storage } from 'komet:api';
|
||||||
|
```
|
||||||
|
|
||||||
|
Все методы возвращают `Promise`. При ошибке `Promise` отклоняется с `Error`, в
|
||||||
|
`message` — причина.
|
||||||
|
|
||||||
|
### chat
|
||||||
|
|
||||||
|
`chat.sendText(text)` → `Promise<string>` — отправляет сообщение в текущий чат и
|
||||||
|
возвращает его id. Текст — до 65 536 символов. В зашифрованном чате текст шифруется
|
||||||
|
так же, как обычное сообщение.
|
||||||
|
|
||||||
|
`chat.editText(messageId, text)` → `Promise<void>` — меняет текст сообщения. Можно
|
||||||
|
редактировать только сообщения, отправленные этим же запуском команды.
|
||||||
|
|
||||||
|
`chat.sendPhoto({ base64, url, filename, caption })` → `Promise<void>` — отправляет
|
||||||
|
фото до 15 МБ. Нужен `base64` или `url`; если заданы оба, берётся `base64`. Загрузка
|
||||||
|
по `url` требует разрешения `network`.
|
||||||
|
|
||||||
|
`chat.sendFile({ base64, url, filename })` → `Promise<void>` — отправляет файл до
|
||||||
|
25 МБ, источник задаётся так же. В зашифрованных чатах пока недоступен.
|
||||||
|
|
||||||
|
В `filename` символы кроме латиницы, кириллицы, цифр, `.`, `_`, `-` и пробела
|
||||||
|
заменяются на `_`, длина обрезается до 120 символов. Загрузка по `url` принимает
|
||||||
|
только HTTPS, следует не более чем 5 редиректам и делает до 3 попыток.
|
||||||
|
|
||||||
|
### network
|
||||||
|
|
||||||
|
`network.fetch(url, { method, headers, body })` →
|
||||||
|
`Promise<{ status, headers, body, base64 }>`.
|
||||||
|
|
||||||
|
- Только `https://`. `localhost` и частные IP-адреса запрещены.
|
||||||
|
- Методы: `GET` (по умолчанию), `POST`, `PUT`, `PATCH`, `DELETE`.
|
||||||
|
- Редиректы не выполняются: ответ 301, 302, 303, 307 или 308 завершается ошибкой.
|
||||||
|
- Заголовки `Host`, `Content-Length`, `Connection`, `Proxy-Authorization` игнорируются.
|
||||||
|
- `body` — строка. Объект сначала превратите в JSON: `JSON.stringify(data)`.
|
||||||
|
- Таймауты: соединение 10 с, заголовки ответа 15 с, тело 20 с. Ответ — до 1 МБ.
|
||||||
|
- `body` ответа — текст в UTF-8 (некорректные байты заменяются), `base64` — те же
|
||||||
|
байты без перекодирования, например для `chat.sendPhoto`.
|
||||||
|
- Коды 4xx и 5xx не считаются ошибкой — проверяйте `status`.
|
||||||
|
|
||||||
|
### ui
|
||||||
|
|
||||||
|
`ui.notify(message)` → `Promise<void>` — показывает уведомление Komet внутри
|
||||||
|
приложения, пока открыт чат.
|
||||||
|
|
||||||
|
### contact
|
||||||
|
|
||||||
|
`contact.getPeer()` → `Promise<Peer | null>` — данные собеседника в личном диалоге,
|
||||||
|
всегда свежие с сервера. Вне диалога возвращает `null`.
|
||||||
|
|
||||||
|
`Peer`: `{ id, displayName, country, registrationTime, updateTime, options }`.
|
||||||
|
`registrationTime` и `updateTime` — миллисекунды Unix или `null`, `options` — массив
|
||||||
|
строк-флагов профиля.
|
||||||
|
|
||||||
|
### runtime
|
||||||
|
|
||||||
|
`runtime.sleep(milliseconds)` → `Promise<void>` — пауза от 0 до 10 000 мс.
|
||||||
|
|
||||||
|
`runtime.isOnline()` → `Promise<boolean>` — есть ли соединение с сервером.
|
||||||
|
|
||||||
|
`runtime.isActive()` → `Promise<boolean>` — открыт ли ещё чат, из которого вызвана
|
||||||
|
команда. Проверяйте в длинных сценариях, чтобы не работать впустую.
|
||||||
|
|
||||||
|
### storage
|
||||||
|
|
||||||
|
Постоянное хранилище плагина. Оно переживает обновления и удаляется вместе с плагином.
|
||||||
|
|
||||||
|
`storage.get(key)` → `Promise<value | null>` — значение или `null`, если ключа нет.
|
||||||
|
|
||||||
|
`storage.set(key, value)` → `Promise<void>` — сохраняет любое JSON-значение.
|
||||||
|
|
||||||
|
`storage.remove(key)` → `Promise<void>` — удаляет ключ.
|
||||||
|
|
||||||
|
Ключ — от 1 до 64 символов из `A-Z a-z 0-9 . _ -`. Всё хранилище плагина в JSON — до
|
||||||
|
64 КБ.
|
||||||
|
|
||||||
|
## Среда выполнения
|
||||||
|
|
||||||
|
- Движок — QuickJS. Доступны стандартные объекты языка: `Promise`, `async`/`await`,
|
||||||
|
`JSON`, `Date`, `Math`, `Map`, `RegExp` и т. д.
|
||||||
|
- Нет `console`, `setTimeout`, глобального `fetch`, `require` и модулей Node. Вместо
|
||||||
|
них — `runtime.sleep` и `network.fetch`.
|
||||||
|
- Импортировать можно только `komet:api` и модули своего пакета по относительному
|
||||||
|
пути (`./utils.js`). Выход за пределы пакета (`../`) запрещён.
|
||||||
|
- Каждый запуск команды — новый движок: переменные модуля между запусками не
|
||||||
|
сохраняются, для этого есть `storage`.
|
||||||
|
- Лимиты: 16 МБ памяти, 256 КБ стека, 30 секунд на запуск. По истечении времени
|
||||||
|
движок останавливается.
|
||||||
|
- Необработанное исключение показывается пользователю как «Ошибка плагина: …».
|
||||||
|
|
||||||
|
## Пакет `.kinet`
|
||||||
|
|
||||||
|
- zip-архив до 5 МБ, не больше 128 файлов;
|
||||||
|
- каждый файл до 2 МБ, всё вместе в распакованном виде до 10 МБ;
|
||||||
|
- только `manifest.json` в корне и файлы `.js`;
|
||||||
|
- без символических ссылок, абсолютных путей и `..`;
|
||||||
|
- файл `main` должен быть в архиве.
|
||||||
|
|
||||||
|
## Подпись
|
||||||
|
|
||||||
|
Подписанный плагин показывает при установке отпечаток ключа автора. Обновления
|
||||||
|
подписанного плагина принимаются, только если они подписаны тем же ключом; снять
|
||||||
|
подпись в обновлении тоже нельзя.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run tool/kinet_sign.dart generate-key ~/.komet/hello-key.json
|
||||||
|
dart run tool/kinet_sign.dart sign hello.kinet ~/.komet/hello-key.json hello-signed.kinet
|
||||||
|
dart run tool/kinet_sign.dart verify hello-signed.kinet
|
||||||
|
```
|
||||||
|
|
||||||
|
`generate-key` создаёт ключ Ed25519 с правами `0600` и печатает отпечаток. `sign`
|
||||||
|
добавляет в манифест поле `signature`; без третьего аргумента пакет перезаписывается
|
||||||
|
на месте. Подпись покрывает манифест без поля `signature` и SHA-256 каждого `.js`
|
||||||
|
модуля, поэтому после подписи пакет менять нельзя.
|
||||||
|
|
||||||
|
Храните приватный ключ в секрете. Если он потерян, обновить плагин у пользователей не
|
||||||
|
получится — им придётся удалить его и установить заново.
|
||||||
|
|
||||||
|
## Обновления
|
||||||
|
|
||||||
|
Укажите в манифесте `updateUrl` — HTTPS-адрес JSON-файла:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/KometTeam/Komet/main/plugin_sdk/update-manifest.schema.json",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"packageUrl": "hello-1.1.0.kinet",
|
||||||
|
"size": 2048,
|
||||||
|
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|--------------|----------|
|
||||||
|
| `version` | версия нового пакета, совпадает с `version` в его манифесте |
|
||||||
|
| `packageUrl` | адрес `.kinet`, абсолютный или относительный к `updateUrl`; итоговый адрес — только HTTPS |
|
||||||
|
| `size` | размер пакета в байтах; `0` отключает проверку |
|
||||||
|
| `sha256` | SHA-256 пакета в hex (`shasum -a 256 hello.kinet`); пустая строка отключает проверку |
|
||||||
|
|
||||||
|
Пользователь проверяет обновления из меню плагина. Komet предлагает обновление, только
|
||||||
|
если `version` больше установленной по правилам SemVer, и отказывается его ставить,
|
||||||
|
если:
|
||||||
|
|
||||||
|
- у пакета другой `id` или `version`;
|
||||||
|
- не сошлись `size` или `sha256`;
|
||||||
|
- обновление просит разрешения, которых нет у установленной версии, — такой плагин
|
||||||
|
нужно переустановить;
|
||||||
|
- пакет подписан другим ключом или подпись снята.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# ИКАР
|
||||||
|
|
||||||
|
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
|
||||||
|
|
||||||
|
Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия — `1.0.3+4`, вариант сборки — `ikar`.
|
||||||
|
|
||||||
|
Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Иконка перенесена из прежнего Икара. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
Личные сообщения, групповые чаты и каналы, редактирование и удаление сообщений, ответы и форматирование, реакции, поиск, контакты, блокировки, папки, опросы, истории, загрузка и скачивание файлов, фото, видео и голосовых сообщений, расшифровка русской речи на Pi, стикеры, отложенная отправка, голосовые и видеозвонки, фоновые уведомления, боты и мини-приложения. Настройки оформления, масштаба отметок доставки, плагины и остальные локальные возможности исходного клиента сохранены.
|
||||||
|
|
||||||
|
Сервер предоставляет собственный HTTPS API и события SignalR; клиент не авторизуется на серверах MAX. Файлы выдаются по подписанным ссылкам, сообщения сохраняются в SQLite. Сервер проверяет участие в чате и права публикации в каналах. Повтор запроса с тем же идентификатором сообщения возвращает сохранённое сообщение. Задания отложенной отправки переживают перезапуск сервера.
|
||||||
|
|
||||||
|
Регистрация привязывает аккаунт к ключу Android Keystore. Номер служит идентификатором и **не подтверждается SMS**. Вход на другом устройстве требует доступного механизма привязки/восстановления собственного сервера; приложение не использует SMS-службу MAX. До передачи приложения другим людям нужно определить способ подтверждения номера и восстановления потерянного ключа. Старые аккаунты MAX автоматически не переносятся.
|
||||||
|
|
||||||
|
«Карточка ИКАР» показывает собственный профиль и QR-ссылку. Государственный Цифровой ID, Сферум и чужие каталоги/мини-приложения не являются сервисами ИКАР. Собственные мини-приложения настраиваются владельцем бота через `PUT /api/bots/{botId}/web-app` с телом `{"url":"https://..."}`; данные запуска подписаны HMAC, номер передаётся отдельным запросом после подтверждения в клиенте.
|
||||||
|
|
||||||
|
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
|
||||||
|
|
||||||
|
## Размещение на Pi
|
||||||
|
|
||||||
|
Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`.
|
||||||
|
|
||||||
|
- `current` — ссылка на действующий выпуск в `releases/20261007-video`;
|
||||||
|
- `shared/data` — SQLite, вложения, данные устройств и публикация APK;
|
||||||
|
- `shared/server.env`, `shared/turn.env` — закрытые настройки, доступ 0600;
|
||||||
|
- `backups` — ежедневные согласованные копии SQLite и вложений, хранение 14 дней;
|
||||||
|
- `rollback` — резервная копия прежнего Икара, сохранённая перед удалением.
|
||||||
|
|
||||||
|
Собственный Compose-проект `ikar_server` содержит контейнеры `ikar_server`, `ikar_server_turn` и `ikar_server_asr`. API слушает `127.0.0.1:5099`, TURN — 3478 с портами ретрансляции 49160–49200, распознавание речи доступно только во внутренней сети контейнеров. Использован уже существовавший маршрут Caddy; конфигурации соседних программ не менялись.
|
||||||
|
|
||||||
|
Автозапуск: `ikar_server.service`; резервные копии: `ikar_server-backup.timer` (03:30 с небольшим разбросом времени). Старт требует смонтированного внешнего диска и маркера в каталоге данных.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status ikar_server.service ikar_server-backup.timer
|
||||||
|
curl http://127.0.0.1:5099/health
|
||||||
|
sudo systemctl start ikar_server-backup.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Адрес сервера — `https://ikar.kusoft.xyz`. На проверке 7 октября 2026 года DNS-запись разрешалась в `195.216.241.165`. APK также умеет подключаться к этому IP с сохранением проверки сертификата имени `ikar.kusoft.xyz`, если DNS временно недоступен.
|
||||||
|
|
||||||
|
Для звонков через разные NAT нужен проброс на роутере: 3478 TCP/UDP и 49160–49200 UDP на `192.168.0.185`. При проверке TURN работал через локальный адрес Pi; запросы на внешний адрес не получили ответа TURN. Публичная ретрансляция пока не подтверждена. Звонок с аудио/видео в эмуляторе прошёл через прямое WebRTC-соединение; это не доказывает работу через любую мобильную сеть.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Flutter 3.44.3, Java 17, Android SDK, Rust; для сервера .NET 10. На этой машине используются `C:\Tools\jdk-17`, `C:\Tools\AndroidSdk`, `C:\Tools\gradle-home` и `C:\Tools\pub-cache`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
flutter pub get
|
||||||
|
flutter build apk --release --flavor ikar --no-pub
|
||||||
|
flutter test --no-pub
|
||||||
|
flutter analyze --no-pub --no-fatal-infos lib test
|
||||||
|
dotnet test ikar_server/src/Ikar.Server.Tests/Ikar.Server.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Подпись выпуска хранится в закрытом локальном каталоге `.secrets`; `android/key.properties` не публикуется. Для тестов криптографии нужны нативные библиотеки текущей ОС: можно указать `QLYRA_CRYPTO_LIBRARY` и добавить каталог остальных DLL в `PATH`. Имена библиотек — внутренние имена зависимостей и не определяют название приложения.
|
||||||
|
|
||||||
|
`tool/deploy_ikar.ps1` упаковывает опубликованный `linux-arm64` сервер и обновляет только новый Compose-проект на Pi. Модель Vosk в `ikar_server/asr/model.zip` проверяется по SHA-256 при сборке контейнера. Скрипты в `tool/` для проверок используют временные тестовые аккаунты и удаляют их по завершении.
|
||||||
|
|
||||||
|
## Исправления видео 1.0.3
|
||||||
|
|
||||||
|
Серверный ответ на `VIDEO_PLAY` раньше содержал `external` и `hls`: стандартная JSON-сериализация меняла регистр ключей `EXTERNAL` и `HLS`, которые ожидает клиент. Теперь ключ `EXTERNAL` сохраняет точное написание, а исходный MP4 больше не выдаётся за HLS-плейлист. Выдача файла поддерживает HTTP byte ranges для перемотки.
|
||||||
|
|
||||||
|
Сервер создаёт JPEG-превью и определяет реальные размеры и длительность видео с помощью FFmpeg/FFprobe. Превью сохраняется рядом с исходным файлом; повторные запросы используют готовый результат. Это работает и для прежних загрузок. Картинка выдаётся участникам чата по отдельной подписанной ссылке: токен видео нельзя использовать для превью, удалённые и истёкшие сообщения недоступны. В клиенте обновление вложения или подписанной ссылки перерисовывает существующий пузырь, даже если текст и статус сообщения не изменились.
|
||||||
|
|
||||||
|
142 серверных теста прошли. Живая проверка охватила загрузку MP4 чанком, доставку получателю, историю, скачивание с проверкой SHA-256 обоими участниками, ответы 206 для перемотки, JPEG-превью, метаданные и защиту доступа. На подключённом физическом телефоне с установленной версией 1.0.2 исходное видео 33 092 306 байт открылось в плеере; превью и длительность 0:05 появились в чате после обновления серверных данных. Сервер обновлён с согласованной резервной копией базы и вложений; рабочая база и база из копии прошли проверку целостности. Отчёты: `artifacts/video-server-tests.log`, `artifacts/video-live-verification.json`, `artifacts/deployment-video.json`, `artifacts/video-server-runtime-verification.json`.
|
||||||
|
|
||||||
|
Клиент: 17 целевых тестов прошли; полный прогон — 1147 прошли, 9 пропущены. Анализ изменённых файлов не выявил ошибок и предупреждений (19 информационных замечаний стиля). Отчёты: `artifacts/video-client-tests.log`, `artifacts/flutter-tests-1.0.3.log`, `artifacts/video-client-analyze.log`.
|
||||||
|
|
||||||
|
APK `artifacts/ikar-1.0.3+4.apk` подписан прежним ключом ИКАР; свежие Flutter-библиотеки всех трёх ABI проверены в итоговом файле. SHA-256: `5dd485f9a797f28c0eb522f8098c574c0e523796cd83cf50414cffea86747b71`. Выпуск опубликован в [Аргусе](https://argus.kusoft.xyz/#app=ikar): [скачать ИКАР 1.0.3](https://argus.kusoft.xyz/api/apps/ikar/releases/22ec0d08-3407-464e-bf04-871a4a42315a/download). Публичные манифест, каталог и скачанный APK совпадают по версии, размеру и SHA-256; TLS проверен, база каталога и резервная копия целостны. Другие карточки и сервисы публикация не изменяла. Установка нового APK на физический телефон не выполнена: USB перешёл в offline, затем пользователь выбрал завершить публикацию без установки. Отчёты: `artifacts/apk-jni-verification-1.0.3.json`, `artifacts/argus-publication-1.0.3.json`, `artifacts/argus-public-verification-1.0.3.json`.
|
||||||
|
|
||||||
|
## Исправления 1.0.2
|
||||||
|
|
||||||
|
Раздел FKM убран из настроек уведомлений. FKM принудительно выключена в клиенте и Android: включённая настройка прежнего выпуска сбрасывается, служба останавливается, постоянное уведомление удаляется. Повторный запуск и загрузка Android не включают её обратно. Обычные настройки уведомлений сохранены.
|
||||||
|
|
||||||
|
При отсутствии связи с сервером шапка показывает «Соединение...»; после восстановления связи возвращается обычный заголовок. Сбои подключения, тайм-ауты и ответы шлюза 502/503/504 запускают переподключение без повторяющихся служебных сообщений снизу. Ошибки конкретных операций при работающем соединении по-прежнему отображаются.
|
||||||
|
|
||||||
|
Проверки: полный клиентский прогон — 1144 прошли, 9 пропущены; 15 целевых тестов проверяют отключение FKM, отсутствие её меню, переходы состояния сети и сохранение ошибок операций. Анализ изменённых файлов не выявил ошибок и предупреждений (19 информационных замечаний стиля). Отчёты: `artifacts/flutter-tests-1.0.2.log`, `artifacts/offline-fkm-tests-1.0.2.log`, `artifacts/offline-fkm-analyze-1.0.2.log`.
|
||||||
|
|
||||||
|
APK `artifacts/ikar-1.0.2+3.apk` подписан прежним ключом ИКАР; свежие Flutter-библиотеки всех трёх ABI сверены с содержимым APK. SHA-256: `fd3f4347ea7fe1e078dce3f5d442430eb79ede5bf529a49e8890956e7668b3a6`. В эмуляторе Android API 35 обновление сохранило данные и сбросило старую настройку `enabled=true` в `false`; повторный запуск сохранил отключённое состояние. Служба FKM и её постоянное уведомление отсутствуют, ошибок Android Runtime и Flutter не зарегистрировано. На физическом телефоне этот выпуск не проверялся. Отчёты: `artifacts/apk-jni-verification-1.0.2.json`, `artifacts/emulator-fkm-verification-1.0.2.json`.
|
||||||
|
|
||||||
|
Выпуск опубликован в [Аргусе](https://argus.kusoft.xyz/#app=ikar). [Скачать ИКАР 1.0.2](https://argus.kusoft.xyz/api/apps/ikar/releases/9419489c-6cd6-467d-9f05-3bf3298d92f0/download). Публичные каталог, манифест и скачанный APK проверены по версии, размеру и SHA-256 с проверкой TLS. Перед публикацией создана согласованная резервная копия базы Аргуса; база и копия целостны, другие карточки и работающие сервисы сохранены. Сервер остался на выпуске 1.0.1: изменение относится к Android-клиенту. Отчёты: `artifacts/argus-publication-1.0.2.json`, `artifacts/argus-public-verification-1.0.2.json`.
|
||||||
|
|
||||||
|
## Исправления 1.0.1
|
||||||
|
|
||||||
|
Первое сообщение нового диалога раньше отклонялось локальной SQLite: запись сообщения создавалась до родительской записи чата. Теперь диалог и сообщение сохраняются атомарно; подготовка первого диалога также охватывает фото, файлы, голосовые сообщения и стикеры. Отмена подтверждения отправки не создаёт пустой чат.
|
||||||
|
|
||||||
|
Сервер поддерживает приватное «Избранное» с `chatId=0`, пустую историю нового диалога и подписку по XOR ID. Отключение подписки больше не удаляет участие в чате. Полная синхронизация контактов очищает устаревшие записи, защищена от смены аккаунта и параллельного добавления контакта. Сохранение устаревшего контакта восстанавливает связь через `ADD`; ошибки поиска различают отсутствие пользователя и сбой сервера и отображаются по-русски.
|
||||||
|
|
||||||
|
Проверки: 137 серверных тестов; полный клиентский прогон — 1127 прошли, 9 пропущены; после последних защит синхронизации — 55 целевых тестов. Все 17 живых проверок API прошли, включая отправку в «Избранное», первое сообщение, доставку второму клиенту, историю, контакты и сохранение членства после отключения подписки. Отчёты: `artifacts/messaging-fix-after.json`, `artifacts/focused-tests-1.0.1-final.log`, `artifacts/deployment-1.0.1.json`.
|
||||||
|
|
||||||
|
Сервер обновлён с согласованной резервной копией; соседние контейнеры сохранены. APK `artifacts/ikar-1.0.1+2.apk` имеет прежнюю подпись ИКАР, versionCode 2 и свежие Flutter-библиотеки для всех трёх ABI. SHA-256: `6c23cc0063e63eda03172f10209a09a4dfc11655b435ce7aa982d495e3b329ad`.
|
||||||
|
|
||||||
|
Выпуск опубликован в [Аргусе](https://argus.kusoft.xyz/#app=ikar). [Скачать ИКАР 1.0.1](https://argus.kusoft.xyz/api/apps/ikar/releases/ae0be8b6-9033-4b76-afce-42c9cac566c8/download). База каталога и резервная копия проверены; другие карточки и работающие сервисы сохранены. Передача на физический телефон и проверка исправлений на нём не выполнены: USB-соединение было отключено. Отчёты публикации: `artifacts/argus-publication-1.0.1.json` и `artifacts/argus-public-verification-1.0.1.json`.
|
||||||
|
|
||||||
|
## Проверка первого выпуска 1.0.0
|
||||||
|
|
||||||
|
- APK: `artifacts/ikar-1.0.0+1.apk`, контрольная сумма рядом;
|
||||||
|
- клиент: 1107 тестов прошли, 9 пропущены;
|
||||||
|
- сервер: 133 теста прошли, результаты — `artifacts/server-tests.log`;
|
||||||
|
- живой HTTPS API: `artifacts/live-smoke.json`;
|
||||||
|
- обмен сообщениями из Android-интерфейса: `artifacts/native-message.json`;
|
||||||
|
- звонок с настоящим WebRTC и искусственным аудио/видео: `artifacts/native-call.json`;
|
||||||
|
- фоновое уведомление Android: `artifacts/native-background-notification.json`;
|
||||||
|
- боты и подписанные данные мини-приложений: `artifacts/live-bot-smoke.json`;
|
||||||
|
- локальная ретрансляция TURN в обе стороны: `artifacts/local-turn-verification.json`;
|
||||||
|
- скачивание APK по HTTPS с проверкой SHA-256: `artifacts/release-verification.json`;
|
||||||
|
- итоговое состояние Pi и сохранность соседних сервисов: `artifacts/pi-verification.json`.
|
||||||
|
|
||||||
|
ИКАР 1.0.0 (Android versionCode 1) опубликован в [Аргусе](https://argus.kusoft.xyz/#app=ikar).
|
||||||
|
[Скачать APK](https://argus.kusoft.xyz/api/apps/ikar/releases/5def8cec-6349-437f-ad7b-68ad99bf4a84/download).
|
||||||
|
Публичный файл совпадает с `artifacts/ikar-1.0.0+1.apk` по размеру, SHA-256 и подписи ИКАР.
|
||||||
|
Перед публикацией сохранена согласованная копия базы Аргуса; база и копия прошли проверку целостности.
|
||||||
|
Публикация не изменяла другие карточки и не перезапускала сервисы на Pi.
|
||||||
|
Отчёты: `artifacts/argus-publication.json`, `artifacts/argus-public-verification.json` и `artifacts/argus-apk-signature.txt`.
|
||||||
|
Скрипты первого выпуска: `tool/publish_argus_release.py` и `tool/verify_argus_release.dart`.
|
||||||
|
|
||||||
|
Android проверен в эмуляторе API 35. Проверки на физическом телефоне и в разных мобильных сетях не проводились. Отдельные функции исходного клиента проверены модульными тестами; список живых сценариев приведён в отчётах, поэтому он не означает проверку каждого экрана и интеграции.
|
||||||
|
|
||||||
|
Стикеры: [Noto Emoji v2.047](https://github.com/googlefonts/noto-emoji/tree/v2.047), Apache 2.0 (лицензия рядом с изображениями). Распознавание: [Vosk, русская модель small-ru-0.22](https://alphacephei.com/vosk/models), Apache 2.0.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- .migration/**
|
||||||
|
- ikar_server/**
|
||||||
|
- .migration/**
|
||||||
|
- ikar_server/**
|
||||||
|
- artifacts/**
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
- macos/**
|
||||||
|
- linux/**
|
||||||
|
- native/**
|
||||||
|
- third_party/**
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Russian Trusted Root CA, downloaded over verified HTTPS from:
|
||||||
|
https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt
|
||||||
|
|
||||||
|
Official distribution page: https://www.gosuslugi.ru/crt
|
||||||
|
SHA-256 certificate fingerprint:
|
||||||
|
D2:6D:2D:02:B7:C3:9F:92:CC:73:85:12:BA:54:10:35:19:E4:40:5D:68:B5:BD:70:3E:97:88:CA:8E:CF:31
|
||||||
|
|
||||||
|
Used only by the media HTTP client for HTTPS on oneme.ru and its subdomains.
|
||||||
|
System trust, certificate-chain validation, expiry, and hostname validation
|
||||||
|
remain enabled. The CA is not installed into the OS or global Dart trust store.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||||
|
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||||
|
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||||
|
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
||||||
|
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||||
|
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
||||||
|
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
||||||
|
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
||||||
|
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
||||||
|
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
||||||
|
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
||||||
|
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
||||||
|
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
||||||
|
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
||||||
|
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
||||||
|
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
||||||
|
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
||||||
|
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
||||||
|
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
||||||
|
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
||||||
|
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
||||||
|
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
||||||
|
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
||||||
|
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
||||||
|
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
||||||
|
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
||||||
|
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
||||||
|
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
||||||
|
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
||||||
|
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
||||||
|
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
||||||
|
-----END CERTIFICATE-----
|
||||||