Import IKAR messenger client and server

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

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

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

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

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

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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

Some files were not shown because too many files have changed in this diff Show More